diff --git a/.changepacks/changepack_log_release-0-2-0-bridge.json b/.changepacks/changepack_log_release-0-2-0-bridge.json new file mode 100644 index 00000000..87437947 --- /dev/null +++ b/.changepacks/changepack_log_release-0-2-0-bridge.json @@ -0,0 +1 @@ +{"changes":{"Cargo.toml":"Minor","libs/vespera-bridge/build.gradle.kts":"Minor","libs/vespera-bridge-gradle-plugin/build.gradle.kts":"Minor"},"note":"0.2.0 / 0.3.0 release — BREAKING (0.x minor): DecodedResponse.body() returns read-only ByteBuffer (bodyBytes() copies on demand); SmartDispatchModeResolver is the autoconfigured default (DIRECT ~2.2µs / SYNC ~3.2µs for small requests, opt out via vespera.bridge.dispatch-mode=bidirectional-streaming); Gradle plugin now also publishes to the Plugin Portal. Perf: JMethodID+GlobalRef caching for streaming closures, daemon-attached dispatchAsync completion, lazy bidirectional request-pull (spawn on first body poll), JsonGenerator wire-header encoding, zero-copy get_byte_array_region input conversion. Rust: Validated 422 envelope via derive(Serialize) (byte-identical, snapshot-locked), per-invocation fs::metadata epoch caching in vespera_macro, collector clone elimination. See libs/vespera-bridge/docs/jni-before-after-2026-06-11.md for measured numbers.","date":"2026-06-12T13:00:00.000Z"} diff --git a/.changepacks/config.json b/.changepacks/config.json index a28a25b8..46391859 100644 --- a/.changepacks/config.json +++ b/.changepacks/config.json @@ -9,6 +9,7 @@ ] }, "publish": { - "java": "./gradlew publishToMavenCentral --stacktrace" + "java": "./gradlew publishToMavenCentral --stacktrace", + "libs/vespera-bridge-gradle-plugin/build.gradle.kts": "./gradlew publishToMavenCentral publishPlugins --stacktrace" } } \ No newline at end of file diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index bc06cc0e..64aafea1 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -39,6 +39,11 @@ jobs: run: cargo clippy --all-targets --all-features -- -D warnings - name: Test Deploy run: cargo publish --dry-run + - name: Doctest + # tarpaulin's --all-targets / default run never compiles doc + # tests, which let a never-passing doctest land unnoticed — + # run them explicitly before the (slow) coverage step. + run: cargo test --workspace --doc - name: Test run: | # rust coverage issue @@ -53,7 +58,7 @@ jobs: cargo fmt cargo tarpaulin --out Lcov Stdout --engine llvm - name: Upload to codecov.io - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true @@ -64,7 +69,9 @@ jobs: changepacks: name: changepacks runs-on: ubuntu-latest - needs: test + # jni-e2e gates publishing: a release must never ship with a broken + # JNI dispatch path on any supported OS. + needs: [test, jni-e2e] permissions: # create pull request comments pull-requests: write @@ -101,6 +108,75 @@ jobs: # GPG signing (in-memory key, no keyring file) ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_SIGNING_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_SIGNING_PASSWORD }} + # Gradle Plugin Portal credentials (read natively by + # com.gradle.plugin-publish for the `publishPlugins` task) + GRADLE_PUBLISH_KEY: ${{ secrets.GRADLE_PUBLISH_KEY }} + GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }} outputs: changepacks: ${{ steps.changepacks.outputs.changepacks }} release_assets_urls: ${{ steps.changepacks.outputs.release_assets_urls }} + + # JNI end-to-end tests — builds the rust-jni-demo cdylib, publishes the + # vespera-bridge JAR to mavenLocal (so the demo-app Gradle plugin can + # resolve kr.devfive:vespera-bridge:0.1.1), then runs the full + # :demo-app:test suite (StreamingClosureStressTest + JNI dispatch tests) + # across all three target host OSes. This is the project's only Java/JNI + # coverage gate — until now the workflow ran zero JNI tests. + # + # Runs unconditionally on every push/PR (matching the existing CI job's + # style — no per-job paths-filter). The whole workflow already inherits + # the workflow-level `paths-ignore` for docs-only changes. + jni-e2e: + name: JNI E2E (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Build rust-jni-demo cdylib (release) + # The vespera-bridge Gradle plugin's bundleNativeLib task copies + # this cdylib from target/release into demo-app's resources, so it + # must exist before `:demo-app:test` (processResources) runs. + run: cargo build -p rust-jni-demo --release + - name: Make gradlew executable (unix) + if: runner.os != 'Windows' + run: | + chmod +x libs/vespera-bridge/gradlew + chmod +x libs/vespera-bridge-gradle-plugin/gradlew + chmod +x examples/rust-jni-demo/java/gradlew + - name: Publish vespera-bridge Gradle plugin to mavenLocal + # demo-app's plugins block resolves kr.devfive.vespera-bridge from + # mavenLocal (settings.gradle.kts pluginManagement) — the plugin is + # not on the Gradle Plugin Portal. + shell: bash + working-directory: libs/vespera-bridge-gradle-plugin + run: ./gradlew publishToMavenLocal --console=plain --no-daemon + - name: Publish vespera-bridge to mavenLocal + # demo-app resolves kr.devfive:vespera-bridge:0.1.1 from mavenLocal + # (see examples/rust-jni-demo/java/demo-app/build.gradle.kts — + # bridgeVersion.set("0.1.1")). + shell: bash + working-directory: libs/vespera-bridge + run: ./gradlew publishToMavenLocal --console=plain --no-daemon + - name: Run demo-app JNI E2E tests + # Includes StreamingClosureStressTest (1000 × 1 MiB SHA256 + # bidirectional round-trip). Bench knobs are NOT propagated — + # gated bench tests stay skipped in CI. + shell: bash + working-directory: examples/rust-jni-demo/java + run: ./gradlew :demo-app:test --console=plain --no-daemon + - name: Upload demo-app test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: jni-e2e-${{ matrix.os }}-test-results + path: examples/rust-jni-demo/java/demo-app/build/test-results/test/*.xml diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml new file mode 100644 index 00000000..838e0000 --- /dev/null +++ b/.github/workflows/bench.yml @@ -0,0 +1,105 @@ +name: Bench + +# Criterion regression gate for the in-process dispatch hot path. +# +# - push to main: runs the gated bench groups and saves the results as +# the `main` criterion baseline in the actions cache. +# - pull_request: restores the latest main baseline and compares; the +# job FAILS when any bench regresses by more than 10% mean change +# AND the 95% confidence interval lower bound exceeds +5% (the +# double condition filters shared-runner noise). +# +# Gated groups are the stable per-request paths (wire_path, +# headers_path, resolve_path). The streaming groups are noisier +# (spawn_blocking scheduling) and are validated locally instead — see +# PERF_REPORT.md. + +on: + push: + branches: + - main + paths: + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.github/workflows/bench.yml' + pull_request: + paths: + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.github/workflows/bench.yml' + +concurrency: + group: bench-${{ github.ref }} + cancel-in-progress: true + +env: + BENCH_FILTER: 'wire_path|headers_path|resolve_path' + +jobs: + bench: + name: Criterion regression gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Restore criterion baseline (latest main) + id: restore-baseline + uses: actions/cache/restore@v5 + with: + path: target/criterion + key: bench-baseline-${{ runner.os }}-${{ github.sha }} + restore-keys: | + bench-baseline-${{ runner.os }}- + + - name: Run benches and save main baseline + if: github.event_name == 'push' + run: | + cargo bench -p vespera_inprocess --bench dispatch -- \ + --save-baseline main "${BENCH_FILTER}" + + - name: Save criterion baseline cache + if: github.event_name == 'push' + uses: actions/cache/save@v5 + with: + path: target/criterion + key: bench-baseline-${{ runner.os }}-${{ github.sha }} + + - name: Compare against main baseline + if: github.event_name == 'pull_request' + run: | + if [ ! -d target/criterion ] || ! find target/criterion -maxdepth 4 -type d -name main | grep -q .; then + echo "::notice::No main baseline in cache yet — running benches without a gate." + cargo bench -p vespera_inprocess --bench dispatch -- "${BENCH_FILTER}" + exit 0 + fi + cargo bench -p vespera_inprocess --bench dispatch -- \ + --baseline main "${BENCH_FILTER}" + + - name: Enforce regression gate + if: github.event_name == 'pull_request' + run: | + shopt -s nullglob + fail=0 + found=0 + while IFS= read -r f; do + found=1 + mean=$(jq -r '.mean.point_estimate' "$f") + lower=$(jq -r '.mean.confidence_interval.lower_bound' "$f") + bench=$(dirname "$(dirname "$f")") + bench=${bench#target/criterion/} + printf '%s: mean %+.2f%% (CI lower %+.2f%%)\n' \ + "$bench" "$(awk -v v="$mean" 'BEGIN{print v*100}')" \ + "$(awk -v v="$lower" 'BEGIN{print v*100}')" + if awk -v m="$mean" -v l="$lower" 'BEGIN{exit !(m > 0.10 && l > 0.05)}'; then + echo "::error::Performance regression: ${bench} mean change exceeds +10% with CI lower bound > +5%" + fail=1 + fi + done < <(find target/criterion -path '*/change/estimates.json') + if [ "$found" -eq 0 ]; then + echo "::notice::No change estimates found (first run against this baseline?) — nothing to gate." + fi + exit $fail diff --git a/AGENTS.md b/AGENTS.md index f3cf0a5f..ff28aac6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,8 @@ vespera/ │ ├── vespera_inprocess/ # In-process dispatch (transport-agnostic) │ │ └── src/lib.rs # dispatch(), register_app(), dispatch_from_bytes() │ └── vespera_jni/ # JNI bridge (depends on vespera_inprocess) -│ └── src/lib.rs # RUNTIME, jni_app! macro, JNI symbol export +│ ├── src/jni_impl.rs # RUNTIME, jni_app! macro, JNI symbol export +│ └── src/streaming_closures.rs # Streaming closure factories + JMethodID cache ├── libs/ │ └── vespera-bridge/ # Java library (com.devfive.vespera.bridge) │ ├── VesperaBridge.java # JNI native loader + dispatch @@ -62,9 +63,9 @@ vespera/ | Modify schema_type! macro | `crates/vespera_macro/src/schema_macro.rs` | Type derivation & SeaORM support | | Add core types | `crates/vespera_core/src/` | OpenAPI spec types | | Test new features | `examples/axum-example/` | Add route, run example | -| In-process dispatch | `crates/vespera_inprocess/src/lib.rs` | RequestEnvelope → Router → ResponseEnvelope | -| App factory (FFI pattern) | `crates/vespera_inprocess/src/lib.rs` | register_app(), dispatch_from_bytes() | -| JNI integration | `crates/vespera_jni/src/lib.rs` | RUNTIME, jni_app! macro, JNI symbol export | +| In-process dispatch | `crates/vespera_inprocess/src/dispatch.rs` | RequestEnvelope → Router → ResponseEnvelope; wire + direct-write entry points | +| App factory (FFI pattern) | `crates/vespera_inprocess/src/registry.rs` | register_app(), resolve_app_router() | +| JNI integration | `crates/vespera_jni/src/jni_impl.rs` | RUNTIME, jni_app! macro, JNI symbol export | | Java bridge library | `libs/vespera-bridge/` | com.devfive.vespera.bridge package | | JNI demo (Rust) | `examples/rust-jni-demo/src/` | Routes + vespera::jni_app! | | JNI demo (Java) | `examples/rust-jni-demo/java/` | Spring Boot proxy app | @@ -79,8 +80,15 @@ vespera/ | `vespera_macro/src/parser/parameters.rs` | ~845 | Extract path/query params from handlers | | `vespera_macro/src/openapi_generator.rs` | ~808 | OpenAPI doc assembly | | `vespera_macro/src/collector.rs` | ~707 | Filesystem route scanning | -| `vespera_inprocess/src/lib.rs` | ~175 | In-process dispatch + app factory | -| `vespera_jni/src/lib.rs` | ~95 | JNI RUNTIME + jni_app! macro + JNI symbol | +| `vespera_inprocess/src/lib.rs` | ~85 | Crate root: module wiring + public re-exports (modularized — logic lives in the files below) | +| `vespera_inprocess/src/wire.rs` | ~429 | Binary wire encode/decode: split/parse, `Cow` borrowing request header, `HeaderMap`-direct response serialization, 422 validation-error hoisting | +| `vespera_inprocess/src/dispatch.rs` | ~290 | Public dispatch entry points: text envelope API, binary wire API, direct-write (`dispatch_into`) API | +| `vespera_inprocess/src/internal.rs` | ~335 | Request building + router oneshot + response collection (malformed path/header → 400) | +| `vespera_inprocess/src/streaming.rs` | ~462 | Response / header-callback / bidirectional streaming; `RequestChunk`/`StreamAbort` error-aware request body; bounded `ChannelBody` | +| `vespera_inprocess/src/registry.rs` | ~200 | App registration + lock-free default-app `OnceLock` + named-app `RwLock` | +| `vespera_jni/src/jni_impl.rs` | ~880 | JNI RUNTIME + jni_app! macro + 7 JNI symbols (incl. direct-buffer path) | +| `vespera_jni/src/streaming_closures.rs` | ~410 | Streaming closure factories (`make_pull_closure`, `make_push_closure`, `call_header_consumer`, `complete_future`) + `OnceLock` caching `JMethodID`+`GlobalRef` for `InputStream.read`, `OutputStream.write`, `Consumer.accept`, `CompletableFuture.complete` — `call_method_unchecked` on the hot path. Pull/push/header closures attach via [`daemon_env::with_cached_daemon_env`] (TLS-cached daemon attach), not `attach_current_thread` per chunk | +| `vespera_jni/src/daemon_env.rs` | ~210 | `with_cached_daemon_env(jvm, cb)` — resolves the current OS thread's `JNIEnv` once via `GetEnv` and caches it in a `thread_local!` `RefCell>`, reused for every JNI callback on that thread (streaming chunk pull/push, header callbacks, async `CompletableFuture.complete`). Already-attached JVM threads are **borrowed** (never detached); unattached Tokio/`spawn_blocking` threads are **owned** (attached via `AttachCurrentThreadAsDaemon`, detached in the TLS `Drop` on thread exit). Replaces the prior per-chunk attach/detach churn; per-call local frame + exception scrub preserved | ## CRATE DEPENDENCY GRAPH @@ -134,6 +142,7 @@ Feature flags: |---------|-----------|------| | `inprocess` | `vespera::inprocess` (= `vespera_inprocess`) | dispatch, register_app, envelopes | | `jni` | `vespera::jni` (= `vespera_jni`) + implies `inprocess` | RUNTIME, jni_app!, JNI symbol | +| `mimalloc` | (with `jni`) mimalloc as the cdylib's `#[global_allocator]` | measured -15~19% on sync/direct dispatch vs Windows HeapAlloc | ## JNI ARCHITECTURE @@ -170,7 +179,7 @@ bytes 4+N.. : raw body bytes (UTF-8 text or binary — - All failure modes (malformed wire, panic in Rust, no app registered) return a valid length-prefixed wire response, so the Java decoder never has to special-case errors. - `validation_errors` is an optional array hoisted from 422 JSON bodies (`{"errors":[...]}`) — original body preserved verbatim alongside. -### JNI Dispatch Modes (four symbols) +### JNI Dispatch Modes (seven symbols) | Symbol | Java native | Mode | Memory | |---|---|---|---| @@ -178,8 +187,13 @@ bytes 4+N.. : raw body bytes (UTF-8 text or binary — | `Java_...dispatchAsync` | `void dispatchAsync(CompletableFuture, byte[])` | async | full body | | `Java_...dispatchStreaming` | `byte[] dispatchStreaming(byte[], OutputStream)` | sync response-streaming | chunk-bounded response | | `Java_...dispatchFullStreaming` | `byte[] dispatchFullStreaming(byte[], InputStream, OutputStream)` | sync bidirectional streaming | chunk-bounded both directions | +| `Java_...dispatchStreamingWithHeader` | `void dispatchStreamingWithHeader(byte[], Consumer, OutputStream)` | sync response-streaming, header callback before first body byte | chunk-bounded response | +| `Java_...dispatchFullStreamingWithHeader` | `void dispatchFullStreamingWithHeader(byte[], Consumer, InputStream, OutputStream)` | sync bidirectional streaming, header callback | chunk-bounded both directions | +| `Java_...dispatchDirect0` | `int dispatchDirect(ByteBuffer, int, ByteBuffer)` (public validated wrapper over the private native) | sync, direct buffers | full body, zero Java heap arrays | -All four share the same wire format, registered router, and panic-safe `catch_unwind` discipline. `dispatchAsync` spawns the dispatch on Rust's shared Tokio runtime via `tokio::spawn` (panic → `JoinError` → `error_wire(500)`) and completes the `CompletableFuture` from a worker thread via `attach_current_thread`. `dispatchStreaming` drains the response body chunk-by-chunk via `http_body::Body::frame()` and writes each chunk to the Java `OutputStream`. `dispatchFullStreaming` adds request-side streaming: a `tokio::task::spawn_blocking` thread pulls 16 KiB chunks from `InputStream.read(byte[])` and feeds them into axum via an `mpsc::channel`-backed `http_body::Body`, giving natural backpressure (bounded 16-slot channel) so 1 GiB uploads run in `O(chunk_size)` RAM. +All share the same wire format, registered router, and panic-safe `catch_unwind` discipline. **`DecodedResponse` (vespera-bridge 0.2.0, BREAKING):** `body()` now returns a read-only `java.nio.ByteBuffer` (zero-copy view over the wire bytes); `bodyBytes()` materialises an owned `byte[]` copy on demand — callers that previously used `body()` as `byte[]` must switch to `bodyBytes()`. The direct-buffer path (`dispatchDirect` + pooled `dispatchDirectPooled`, per-thread 64 KiB→4 MiB buffers via `vespera.direct.maxBufferBytes`) removes the two JNI region copies of `dispatchBytes`; on response overflow it returns `-(requiredSize)` and a retry **re-runs the handler**, so the Java side only auto-retries idempotent requests (`BufferTooSmallException` otherwise). Spring autoconfigured default since vespera-bridge 0.2.0: `SmartDispatchModeResolver` (small/bodyless idempotent → `DIRECT` ~2.2µs, small non-idempotent → `SYNC` ~3.2µs, else streaming ~24µs). Opt out with `vespera.bridge.dispatch-mode=bidirectional-streaming` to restore the pre-0.2.0 default (`BidirectionalStreamingDispatchModeResolver`: provably bodyless requests — CL:0, or GET/HEAD/OPTIONS without CL/TE — downgrade to response-only `STREAMING` ~3x, 24.1→7.7µs; everything else streams both ways). `dispatchAsync` spawns the dispatch on Rust's shared Tokio runtime via `tokio::spawn` (panic → `JoinError` → `error_wire(500)`) and completes the `CompletableFuture` from a daemon-attached cached Tokio worker thread (`with_async_daemon_env` in `jni_impl.rs`: raw `AttachCurrentThreadAsDaemon` + TLS env cache + per-completion local frame + unconditional pending-exception cleanup) — ~1.3µs/op faster than scoped attach per completion. `dispatchStreaming` drains the response body chunk-by-chunk via `http_body::Body::frame()` and writes each chunk to the Java `OutputStream`. `dispatchFullStreaming` adds request-side streaming: a `tokio::task::spawn_blocking` thread pulls chunks (default 256 KiB) from `InputStream.read(byte[])` and feeds them into axum via an `mpsc::channel`-backed `http_body::Body`, giving natural backpressure (bounded channel, default 16 slots) so 1 GiB uploads run in `O(chunk_size)` RAM. + +**Streaming tuning (process-fixed after first dispatch):** chunk size via system property `vespera.streaming.chunkBytes` / env `VESPERA_STREAMING_CHUNK_BYTES` (default 256 KiB, clamped 4 KiB–8 MiB); channel capacity via `vespera.streaming.channelCapacity` / `VESPERA_STREAMING_CHANNEL_CAPACITY` (default 16, clamped 1–1024). Java API: `VesperaBridge.configureStreaming(chunkBytes, channelCapacity)` — pending-config pattern (call before `init()`; values stored pending and applied right after native load, before any dispatch; programmatic > sysprops > env > defaults). Rust-side setters: `vespera_inprocess::set_streaming_chunk_bytes` / `set_streaming_channel_capacity` (precedence: setter > env > default). The shared Tokio runtime's worker count is tunable the same way: `vespera.runtime.workerThreads` / `VESPERA_RUNTIME_WORKERS` (default: logical CPUs, clamped 1–1024) — cap it when JVM thread pools compete for the same cores. `_default`-app dispatch resolves through a lock-free `OnceLock` fast path; named apps go through the `RwLock`. The response wire header serializes straight from `http::HeaderMap` (zero per-header allocation) and request wire headers deserialize borrowing from the input buffer (`Cow`) — the wire byte layout is locked by `crates/vespera_inprocess/tests/wire_contract.rs`. ### Rust Public API (vespera_inprocess) @@ -190,7 +204,11 @@ All four share the same wire format, registered router, and panic-safe `catch_un | `dispatch_from_bytes(Vec, &Runtime) -> Vec` | sync | FFI entry, blocks on runtime | | `dispatch_from_bytes_async(Vec) -> Vec` (async) | async | inside an existing runtime | | `dispatch_streaming_async(Vec, F) -> Vec` (async) | response streaming async | `F: FnMut(&[u8])` body chunks | +| `dispatch_streaming_with_header_async(Vec, H, F)` (async) | response streaming, header callback first | `H: FnMut(&[u8])` fires before first body chunk | | `dispatch_bidirectional_streaming(Vec, P, F) -> Vec` (async) | bidirectional streaming | `P: FnMut() -> Option> + Send + 'static`, `F: FnMut(&[u8])` | +| `dispatch_bidirectional_streaming_with_header(Vec, P, F, H)` (async) | bidirectional streaming, header callback | header before first body chunk | +| `dispatch_into(Vec, &mut [u8], &Runtime) -> DirectWriteResult` | sync | direct-write FFI entry — wire response streamed straight into the caller's buffer (no response `Vec`); `Complete(n)` / `Overflow(exact_required)`; 422 materialised internally to keep `validation_errors` hoisting | +| `dispatch_into_async(Vec, &mut [u8]) -> DirectWriteResult` (async) | async | same, inside an existing runtime | | `error_wire(u16, &str) -> Vec` | sync | wire-format error builder | | `dispatch_typed(Router, &RequestEnvelope) -> ResponseEnvelope` | async | direct axum API (BC) | @@ -225,7 +243,7 @@ vespera::jni_apps! { // multi-app primary API `@ConditionalOnMissingBean`: - `AppNameResolver` (default: `HeaderAppNameResolver("X-Vespera-App")`) — picks app per request -- `DispatchModeResolver` (default: `BidirectionalStreamingDispatchModeResolver`) — picks `DispatchMode` +- `DispatchModeResolver` (default since vespera-bridge 0.2.0: `SmartDispatchModeResolver` — small/bodyless idempotent → `DIRECT` ~2.2µs, small non-idempotent → `SYNC` ~3.2µs, else `BIDIRECTIONAL_STREAMING` ~24µs; `vespera.bridge.dispatch-mode=bidirectional-streaming` restores pre-0.2.0 `BidirectionalStreamingDispatchModeResolver` — bodyless requests take response-only `STREAMING`, everything else bidirectional) — picks `DispatchMode` Property `vespera.bridge.controller-enabled=false` disables the whole controller for BYO scenarios. See [`libs/vespera-bridge/README.md`](libs/vespera-bridge/README.md#customization) for the customization recipes. @@ -323,7 +341,7 @@ props only. | Concern | Location | |---|---| | Macro integration tests | `crates/vespera_macro/tests/` (+ `insta` snapshots) | -| Validated/422 contract | `crates/vespera/tests/validated_extractor.rs`, `crates/vespera/tests/jni_validation.rs` | +| Validated/422 contract | `crates/vespera/tests/validated_extractor.rs`, `crates/vespera/tests/jni_validation.rs` | Envelope built via `#[derive(Serialize)]` structs (not `serde_json::json!`); exact bytes locked by `insta::assert_snapshot!` in `validated_extractor.rs` | | Core unit tests | `crates/vespera_core/src/**` inline `#[cfg(test)]` | | JNI end-to-end | `examples/rust-jni-demo` (Rust + Java + Gradle) | | Front tests | `apps/front/src/__tests__/` (`bun test` + `bun-test-env-dom`) | @@ -337,6 +355,7 @@ props only. ## CONVENTIONS +- **File size cap**: every source file stays ≤ 1000 lines. Unit tests live **inline** (`#[cfg(test)] mod tests`) whenever code + tests fit the cap; only when they don't, tests move to sidecar child modules (`/tests.rs`, `/tests_.rs` — `use super::*` semantics preserved). Token-stream assertions use rstest cases + insta snapshots (explicit per-case snapshot names; `prettyplease` for item output) instead of `contains` probes. - **Rust 2024 edition** across all crates - **Workspace dependencies**: Internal crates use `{ workspace = true }` - **Test frameworks**: `rstest` for unit tests, `insta` for snapshots @@ -344,7 +363,7 @@ props only. - **No direct axum dep in examples**: Use `vespera::axum` re-export - **No direct vespera_jni/vespera_inprocess dep**: Use `vespera` features - **Java package**: `com.devfive.vespera.bridge` (fixed for JNI symbol stability) -- **Java build**: Gradle (Kotlin DSL), published to GitHub Packages +- **Java build**: Gradle (Kotlin DSL), published to Maven Central (`kr.devfive:vespera-bridge`, `kr.devfive:vespera-bridge-gradle-plugin`) via changepacks → `./gradlew publishToMavenCentral` (vanniktech maven-publish + GPG in-memory signing) ## ANTI-PATTERNS (THIS PROJECT) @@ -382,6 +401,9 @@ java -jar demo-app/build/libs/demo-app-0.1.0.jar # Check generated OpenAPI cat examples/axum-example/openapi.json + +# CI: jni-e2e job (3-OS matrix: ubuntu/windows/macos) runs demo-app E2E tests +# including StreamingClosureStressTest — see .github/workflows/CI.yml ``` ## NOTES @@ -392,3 +414,5 @@ cat examples/axum-example/openapi.json - Generic types in schemas require `#[derive(Schema)]` on all type params - JNI native library can be bundled inside the fat JAR for single-file deployment - `VesperaBridge.init()` auto-extracts bundled native lib to temp, falls back to system path +- JNI dispatch perf benchmarks: `libs/vespera-bridge/docs/jni-before-after-2026-06-11.md` (note: root `/docs` is gitignored) +- `vespera_macro` file_cache: per-macro-invocation epoch caching of `fs::metadata` (`bump_epoch` called at every file-cache-reaching entry point — `vespera!`, `schema_type!`, `schema!`, `export_app!`, `#[derive(Schema)]`); `collector.rs` clone-optimized diff --git a/Cargo.lock b/Cargo.lock index d2452f23..c14ec781 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -404,9 +404,9 @@ dependencies = [ [[package]] name = "axum-test" -version = "20.0.0" +version = "20.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a86bfe2ef15bee102ac34912f7f4542b0bb37dc464fa55461763999c4d625e7" +checksum = "43c6a2f1d97ee33c39f13dacc0f84ae781a9c2ed373a75bad1129094f5a7c4bd" dependencies = [ "anyhow", "axum", @@ -436,12 +436,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bigdecimal" version = "0.4.10" @@ -458,9 +452,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] @@ -486,6 +480,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "borsh" version = "1.6.1" @@ -573,9 +576,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.2.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", "shlex", @@ -606,9 +609,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -680,6 +683,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "combine" version = "4.6.7" @@ -692,9 +701,9 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ "castaway", "cfg-if", @@ -724,12 +733,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "const-random" version = "0.1.18" @@ -888,14 +891,32 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.20.11" @@ -931,17 +952,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - [[package]] name = "deranged" version = "0.5.8" @@ -1017,17 +1027,26 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", - "subtle", + "block-buffer 0.10.4", + "crypto-common 0.1.6", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "ctutils", ] [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -1102,13 +1121,12 @@ dependencies = [ [[package]] name = "etcetera" -version = "0.8.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ "cfg-if", - "home", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -1124,9 +1142,9 @@ dependencies = [ [[package]] name = "expect-json" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "869f97f4abe8e78fc812a94ad6b721d72c4fb5532877c79610f2c238d7ccf6c4" +checksum = "5e80819dbfe83c8a651f5344b08910d0037dac72988aef27ee4e6bedd7ae2e33" dependencies = [ "chrono", "email_address", @@ -1142,9 +1160,9 @@ dependencies = [ [[package]] name = "expect-json-macros" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e6fdf550180a6c29a28cb9aac262dc0064c25735641d2317f670075e9a469d9" +checksum = "c0637949cd816934f3b7aab44ff98e7ec1fb903c379e07dcb9eac943ec33499e" dependencies = [ "proc-macro2", "quote", @@ -1165,9 +1183,9 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flume" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ "futures-core", "futures-sink", @@ -1186,6 +1204,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1318,9 +1342,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", @@ -1396,9 +1420,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1406,6 +1428,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashbrown" @@ -1415,11 +1442,11 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -1434,7 +1461,7 @@ dependencies = [ "http", "httpdate", "mime", - "sha1", + "sha1 0.10.6", ] [[package]] @@ -1466,36 +1493,27 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hkdf" -version = "0.12.4" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ "hmac", ] [[package]] name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "home" -version = "0.5.12" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "windows-sys 0.61.2", + "digest 0.11.3", ] [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -1536,11 +1554,20 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -1728,22 +1755,11 @@ dependencies = [ "serde_core", ] -[[package]] -name = "inherent" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c727f80bfa4a6c6e2508d2f05b6f4bfce242030bd88ed15ae5331c5b5d30fba7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "insta" -version = "1.47.2" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" dependencies = [ "console", "once_cell", @@ -1835,25 +1851,15 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] - [[package]] name = "leb128fmt" version = "0.1.0" @@ -1930,22 +1936,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] -name = "libredox" -version = "0.1.16" +name = "libmimalloc-sys" +version = "0.1.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" dependencies = [ - "bitflags", - "libc", - "plain", - "redox_syscall 0.7.5", + "cc", ] [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ "cc", "pkg-config", @@ -1975,9 +1978,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.30" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "mac_address" @@ -1998,19 +2001,19 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "md-5" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest", + "digest 0.11.3", ] [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memoffset" @@ -2021,6 +2024,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mimalloc" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +dependencies = [ + "libmimalloc-sys", +] + [[package]] name = "mime" version = "0.3.17" @@ -2029,9 +2041,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -2092,22 +2104,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.6", - "smallvec", - "zeroize", -] - [[package]] name = "num-complex" version = "0.4.6" @@ -2254,20 +2250,11 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link", ] -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -2307,39 +2294,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "plotters" version = "0.3.7" @@ -2598,20 +2558,11 @@ dependencies = [ "bitflags", ] -[[package]] -name = "redox_syscall" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" -dependencies = [ - "bitflags", -] - [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -2632,9 +2583,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "relative-path" @@ -2703,26 +2654,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", -] - [[package]] name = "rstest" version = "0.26.1" @@ -2779,9 +2710,9 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.42.0" +version = "1.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" dependencies = [ "arrayvec", "borsh", @@ -2871,27 +2802,12 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "scc" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" -dependencies = [ - "sdd", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sdd" -version = "3.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" - [[package]] name = "sea-bae" version = "0.2.1" @@ -2907,9 +2823,9 @@ dependencies = [ [[package]] name = "sea-orm" -version = "2.0.0-rc.38" +version = "2.0.0-rc.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b5428ce6a0c8f6b9858df21ad1aa00c2fb94e1c9f344a0436bc855391e5a225" +checksum = "628c3b6acb53ca9942f7f151431ed49db92dafa14d15976a1b9db9d4bd06431c" dependencies = [ "async-stream", "async-trait", @@ -2931,12 +2847,14 @@ dependencies = [ "serde", "serde_json", "sqlx", + "sqlx-core", "strum 0.28.0", "thiserror", "time", "tracing", "url", "uuid", + "web-time", ] [[package]] @@ -2952,9 +2870,9 @@ dependencies = [ [[package]] name = "sea-orm-macros" -version = "2.0.0-rc.38" +version = "2.0.0-rc.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae1374d83dd5b43f14dcc90fc726486c556f4db774b680b12b8c680af76e8233" +checksum = "68a91def07bceb98aab308f7dd16c27496b76a6b7b92b94a61b309b5043d93d5" dependencies = [ "heck 0.5.0", "itertools 0.14.0", @@ -2968,12 +2886,11 @@ dependencies = [ [[package]] name = "sea-query" -version = "1.0.0-rc.33" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b04cdb0135c16e829504e93fbe7880513578d56f07aaea152283526590111828" +checksum = "8d190cfb3bcceb8a8d7d04dee5a0c77f60c7627979cdcb47fdcb8934f009badf" dependencies = [ "chrono", - "inherent", "ordered-float", "rust_decimal", "sea-query-derive", @@ -2984,9 +2901,9 @@ dependencies = [ [[package]] name = "sea-query-derive" -version = "1.0.0-rc.12" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d88ad44b6ad9788c8b9476b6b91f94c7461d1e19d39cd8ea37838b1e6ff5aa8" +checksum = "a0b0f466921cdd3cf4b89d5c3ac2173dba89a873ab395b123a645de181ec7537" dependencies = [ "darling", "heck 0.4.1", @@ -2998,9 +2915,9 @@ dependencies = [ [[package]] name = "sea-query-sqlx" -version = "0.8.0-rc.15" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a04aeecfe00614fece56336fd35dc385bb9ffed0c75660695ba925e42a3991ef" +checksum = "4eaa419cdb9157da1361186b1959983eb2ea0dcb9a3c69dc45c449ecb2af8fef" dependencies = [ "sea-query", "sqlx", @@ -3008,9 +2925,9 @@ dependencies = [ [[package]] name = "sea-schema" -version = "0.17.0-rc.17" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b363dd21c20fe4d1488819cb2bc7f8d4696c62dd9f39554f97639f54d57dd0ab" +checksum = "f88267b43c127956a079895d864fc8318ee37c7f280a7aa33805b714c31995f0" dependencies = [ "async-trait", "sea-query", @@ -3124,24 +3041,23 @@ dependencies = [ [[package]] name = "serial_test" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" dependencies = [ "futures-executor", "futures-util", "log", "once_cell", "parking_lot", - "scc", "serial_test_derive", ] [[package]] name = "serial_test_derive" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", @@ -3156,7 +3072,18 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -3167,14 +3094,25 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -3186,16 +3124,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest", - "rand_core 0.6.4", -] - [[package]] name = "simd_cesu8" version = "1.1.1" @@ -3232,18 +3160,18 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" dependencies = [ "serde", ] [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3258,21 +3186,11 @@ dependencies = [ "lock_api", ] -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - [[package]] name = "sqlx" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" dependencies = [ "sqlx-core", "sqlx-macros", @@ -3283,12 +3201,13 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ "base64", "bytes", + "cfg-if", "chrono", "crc", "crossbeam-queue", @@ -3298,18 +3217,17 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "hashlink", "indexmap", "log", "memchr", - "once_cell", "percent-encoding", "rust_decimal", "rustls", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "thiserror", "time", @@ -3318,14 +3236,14 @@ dependencies = [ "tracing", "url", "uuid", - "webpki-roots 0.26.11", + "webpki-roots", ] [[package]] name = "sqlx-macros" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" dependencies = [ "proc-macro2", "quote", @@ -3336,20 +3254,20 @@ dependencies = [ [[package]] name = "sqlx-macros-core" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" dependencies = [ + "cfg-if", "dotenvy", "either", "heck 0.5.0", "hex", - "once_cell", "proc-macro2", "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx-core", "sqlx-mysql", "sqlx-postgres", @@ -3361,55 +3279,39 @@ dependencies = [ [[package]] name = "sqlx-mysql" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ - "atoi", - "base64", "bitflags", "byteorder", "bytes", "chrono", "crc", - "digest", + "digest 0.11.3", "dotenvy", "either", - "futures-channel", "futures-core", - "futures-io", "futures-util", "generic-array", - "hex", - "hkdf", - "hmac", - "itoa", "log", - "md-5", - "memchr", - "once_cell", "percent-encoding", - "rand 0.8.6", - "rsa", "rust_decimal", "serde", - "sha1", - "sha2", - "smallvec", + "sha1 0.11.0", + "sha2 0.11.0", "sqlx-core", - "stringprep", "thiserror", "time", "tracing", "uuid", - "whoami", ] [[package]] name = "sqlx-postgres" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", "base64", @@ -3425,17 +3327,15 @@ dependencies = [ "hex", "hkdf", "hmac", - "home", "itoa", "log", "md-5", "memchr", - "once_cell", - "rand 0.8.6", + "rand 0.10.1", "rust_decimal", "serde", "serde_json", - "sha2", + "sha2 0.11.0", "smallvec", "sqlx-core", "stringprep", @@ -3448,13 +3348,14 @@ dependencies = [ [[package]] name = "sqlx-sqlite" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" dependencies = [ "atoi", "chrono", "flume", + "form_urlencoded", "futures-channel", "futures-core", "futures-executor", @@ -3464,7 +3365,6 @@ dependencies = [ "log", "percent-encoding", "serde", - "serde_urlencoded", "sqlx-core", "thiserror", "time", @@ -3766,9 +3666,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap", "toml_datetime", @@ -3873,9 +3773,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "typetag" @@ -3960,9 +3860,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -3984,12 +3884,13 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespera" -version = "0.1.51" +version = "0.2.0" dependencies = [ "axum", "axum-extra", "chrono", "garde", + "insta", "serde", "serde_json", "tempfile", @@ -4006,7 +3907,7 @@ dependencies = [ [[package]] name = "vespera_core" -version = "0.1.51" +version = "0.2.0" dependencies = [ "rstest", "serde", @@ -4015,7 +3916,7 @@ dependencies = [ [[package]] name = "vespera_inprocess" -version = "0.1.51" +version = "0.2.0" dependencies = [ "axum", "bytes", @@ -4031,18 +3932,20 @@ dependencies = [ [[package]] name = "vespera_jni" -version = "0.1.51" +version = "0.2.0" dependencies = [ "jni", + "mimalloc", "tokio", "vespera_inprocess", ] [[package]] name = "vespera_macro" -version = "0.1.51" +version = "0.2.0" dependencies = [ "insta", + "prettyplease", "proc-macro2", "quote", "rstest", @@ -4081,9 +3984,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen 0.57.1", ] @@ -4097,17 +4000,11 @@ dependencies = [ "wit-bindgen 0.51.0", ] -[[package]] -name = "wasite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" - [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ "cfg-if", "once_cell", @@ -4119,9 +4016,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4129,9 +4026,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ "bumpalo", "proc-macro2", @@ -4142,9 +4039,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] @@ -4185,21 +4082,22 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] -name = "webpki-roots" -version = "0.26.11" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ - "webpki-roots 1.0.7", + "js-sys", + "wasm-bindgen", ] [[package]] @@ -4213,13 +4111,9 @@ dependencies = [ [[package]] name = "whoami" -version = "1.6.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" -dependencies = [ - "libredox", - "wasite", -] +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" [[package]] name = "winapi" @@ -4311,22 +4205,13 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4338,67 +4223,34 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4411,48 +4263,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -4585,9 +4413,9 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -4608,18 +4436,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", @@ -4649,9 +4477,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 12fd3ae4..6987f42e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,17 @@ license = "Apache-2.0" repository = "https://github.com/dev-five-git/vespera" readme = "README.md" +# Release profile tuned for the shipped artifacts (JNI cdylibs, server +# binaries): thin LTO + single codegen unit trade longer release-build +# time for faster/smaller production code. +# +# NEVER switch the panic strategy away from unwinding here — the JNI +# bridge relies on `catch_unwind` to convert handler panics into `500` +# wire responses; aborting would take down the host JVM instead. +[profile.release] +lto = "thin" +codegen-units = 1 + [workspace.dependencies] vespera_core = { path = "crates/vespera_core", version = "0.2.0" } vespera_macro = { path = "crates/vespera_macro", version = "0.2.0" } diff --git a/apps/landing/next.config.ts b/apps/landing/next.config.ts index 4852158c..4ccc21db 100644 --- a/apps/landing/next.config.ts +++ b/apps/landing/next.config.ts @@ -6,6 +6,9 @@ import type { NextConfig } from 'next' const withMDX = createMDX({ extension: /\.mdx?$/, options: { + // remark-gfm enables GitHub-flavored markdown (pipe tables, strikethrough, + // task lists) — mdx-components.tsx already styles table/th/td elements. + remarkPlugins: ['remark-gfm'], rehypePlugins: ['rehype-slug', 'rehype-pretty-code'], }, }) diff --git a/apps/landing/package.json b/apps/landing/package.json index e05185df..abec4bda 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -13,12 +13,12 @@ "dependencies": { "@devup-api/fetch": "^0.1", "@devup-api/react-query": "^0.1", - "@devup-ui/components": "^0.1.46", + "@devup-ui/components": "^0.1.47", "@devup-ui/react": "^1", "@devup-ui/reset-css": "^1", "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", - "@next/mdx": "^16.2.6", + "@next/mdx": "^16.2.9", "clsx": "^2.1.1", "next": "^16", "react": "^19", @@ -26,13 +26,14 @@ "rehype-sanitize": "^6.0.0", "rehype-slug": "^6.0.0", "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", - "shiki": "^4.1.0", + "shiki": "^4.2.0", "unified": "^11.0.5" }, "devDependencies": { - "@types/mdx": "^2.0.13", + "@types/mdx": "^2.0.14", "@devup-api/next-plugin": "^0.1", "@devup-ui/next-plugin": "^1", "@types/node": "^25", diff --git a/apps/landing/public/images/rust-code.png b/apps/landing/public/images/rust-code.png new file mode 100644 index 00000000..a5ffa1fe Binary files /dev/null and b/apps/landing/public/images/rust-code.png differ diff --git a/apps/landing/public/search.json b/apps/landing/public/search.json index 4e5e7d7f..2459b973 100644 --- a/apps/landing/public/search.json +++ b/apps/landing/public/search.json @@ -1 +1 @@ -[null,null,null,null,{"text":"## What is Devup UI?eeeeeeeeeeee\r\n\r\n**Devup UI is not just another CSS-in-JS library — it's the future of CSS-in-JS itself.**\r\n\r\nDevup UI is a zero-runtime CSS-in-JS preprocessor powered by Rust and WebAssembly. It transforms all your styles at build time, completely eliminating runtime overhead while providing full CSS-in-JS syntax coverage.\r\n\r\n### The Problem with Traditional CSS-in-JS\r\n\r\nTraditional CSS-in-JS solutions force you to choose between:\r\n\r\n- **Developer Experience**: Intuitive APIs, co-located styles, dynamic theming\r\n- **Performance**: No runtime overhead, fast page loads, optimal Core Web Vitals\r\n\r\nLibraries like styled-components and Emotion offer great DX but execute JavaScript at runtime to generate styles. Zero-runtime alternatives like Vanilla Extract sacrifice some flexibility for performance.\r\n\r\n### The Devup UI Solution\r\n\r\nDevup UI eliminates this trade-off entirely. Our Rust-powered preprocessor analyzes your code at build time and handles every CSS-in-JS pattern:\r\n\r\n- **Variables** — Dynamic values become CSS custom properties\r\n- **Conditionals** — Ternary expressions are statically analyzed\r\n- **Responsive Arrays** — Breakpoint-based styles are pre-generated\r\n- **Pseudo Selectors** — `_hover`, `_focus`, `_active` work seamlessly\r\n- **Themes** — Type-safe theme tokens with zero-cost switching\r\n\r\n### Key Advantages\r\n\r\n\r\n \r\n \r\n Feature\r\n Devup UI\r\n styled-components\r\n Emotion\r\n Vanilla Extract\r\n \r\n \r\n \r\n \r\n Zero Runtime\r\n Yes\r\n No\r\n No\r\n Yes\r\n \r\n \r\n Dynamic Values\r\n Yes\r\n Yes\r\n Yes\r\n Limited\r\n \r\n \r\n Full Syntax Coverage\r\n Yes\r\n Yes\r\n Yes\r\n No\r\n \r\n \r\n Type-Safe Themes\r\n Yes\r\n Limited\r\n Limited\r\n Yes\r\n \r\n \r\n Build Performance\r\n Fastest\r\n N/A\r\n N/A\r\n Fast\r\n \r\n \r\n
\r\n\r\n### How It Works\r\n\r\n```tsx\r\n// You write familiar CSS-in-JS syntax\r\nconst example = \r\n\r\n// Devup UI transforms it at build time\r\nconst generated =
\r\n\r\n// With optimized atomic CSS\r\n// .a { background-color: red; }\r\n// .b { padding: 16px; } /* 4 * 4 = 16px */\r\n// .c:hover { background-color: blue; }\r\n```\r\n\r\n> Numeric values are multiplied by 4. `p={4}` becomes `padding: 16px`.\r\n\r\nClass names use compact base-37 encoding (`a`, `b`, ... `z`, `_`, `aa`, `ab`, ...) for minimal CSS output.\r\n\r\n### Familiar API\r\n\r\nIf you've used styled-components or Emotion, you'll feel right at home:\r\n\r\n```tsx\r\nimport { styled } from '@devup-ui/react'\r\n\r\nconst Card = styled('div', {\r\n bg: 'white',\r\n p: 4, // 4 * 4 = 16px\r\n borderRadius: '8px',\r\n boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',\r\n _hover: {\r\n boxShadow: '0 10px 15px rgba(0, 0, 0, 0.1)',\r\n },\r\n})\r\n```\r\n\r\n### Proven Performance\r\n\r\nBenchmarks on Next.js (GitHub Actions - ubuntu-latest):\r\n\r\n\r\n \r\n \r\n Library\r\n Version\r\n Build Time\r\n Build Size\r\n \r\n \r\n \r\n \r\n tailwindcss\r\n 4.1.13\r\n 19.31s\r\n 59,521,539 bytes\r\n \r\n \r\n styleX\r\n 0.15.4\r\n 41.78s\r\n 86,869,452 bytes\r\n \r\n \r\n vanilla-extract\r\n 1.17.4\r\n 19.50s\r\n 61,494,033 bytes\r\n \r\n \r\n kuma-ui\r\n 1.5.9\r\n 20.93s\r\n 69,924,179 bytes\r\n \r\n \r\n panda-css\r\n 1.3.1\r\n 20.64s\r\n 64,573,260 bytes\r\n \r\n \r\n chakra-ui\r\n 3.27.0\r\n 28.81s\r\n 222,435,802 bytes\r\n \r\n \r\n mui\r\n 7.3.2\r\n 20.86s\r\n 97,964,458 bytes\r\n \r\n \r\n **devup-ui (per-file css)**\r\n **1.0.18**\r\n **16.90s**\r\n 59,540,459 bytes\r\n \r\n \r\n **devup-ui (single css)**\r\n **1.0.18**\r\n **17.05s**\r\n **59,520,196 bytes**\r\n \r\n \r\n tailwindcss (turbopack)\r\n 4.1.13\r\n 6.72s\r\n 5,355,082 bytes\r\n \r\n \r\n **devup-ui (single css + turbopack)**\r\n **1.0.18**\r\n 10.34s\r\n **4,772,050 bytes**\r\n \r\n \r\n
\r\n\r\n### Get Started\r\n\r\nReady to experience the future of CSS-in-JS? Head to the [Installation](/docs/installation) guide to get started in minutes.\r\n","title":"What is Devup UI?eeeeeeeeeeee","url":"/documentation/concept/concept-1"},null,null,null,null,null,{"text":"## What is Devup UI?\r\n\r\n**Devup UI is not just another CSS-in-JS library — it's the future of CSS-in-JS itself.**\r\n\r\nDevup UI is a zero-runtime CSS-in-JS preprocessor powered by Rust and WebAssembly. It transforms all your styles at build time, completely eliminating runtime overhead while providing full CSS-in-JS syntax coverage.\r\n\r\n### The Problem with Traditional CSS-in-JS\r\n\r\nTraditional CSS-in-JS solutions force you to choose between:\r\n\r\n- **Developer Experience**: Intuitive APIs, co-located styles, dynamic theming\r\n- **Performance**: No runtime overhead, fast page loads, optimal Core Web Vitals\r\n\r\nLibraries like styled-components and Emotion offer great DX but execute JavaScript at runtime to generate styles. Zero-runtime alternatives like Vanilla Extract sacrifice some flexibility for performance.\r\n\r\n### The Devup UI Solution\r\n\r\nDevup UI eliminates this trade-off entirely. Our Rust-powered preprocessor analyzes your code at build time and handles every CSS-in-JS pattern:\r\n\r\n- **Variables** — Dynamic values become CSS custom properties\r\n- **Conditionals** — Ternary expressions are statically analyzed\r\n- **Responsive Arrays** — Breakpoint-based styles are pre-generated\r\n- **Pseudo Selectors** — `_hover`, `_focus`, `_active` work seamlessly\r\n- **Themes** — Type-safe theme tokens with zero-cost switching\r\n\r\n### Key Advantages\r\n\r\n\r\n \r\n \r\n Feature\r\n Devup UI\r\n styled-components\r\n Emotion\r\n Vanilla Extract\r\n \r\n \r\n \r\n \r\n Zero Runtime\r\n Yes\r\n No\r\n No\r\n Yes\r\n \r\n \r\n Dynamic Values\r\n Yes\r\n Yes\r\n Yes\r\n Limited\r\n \r\n \r\n Full Syntax Coverage\r\n Yes\r\n Yes\r\n Yes\r\n No\r\n \r\n \r\n Type-Safe Themes\r\n Yes\r\n Limited\r\n Limited\r\n Yes\r\n \r\n \r\n Build Performance\r\n Fastest\r\n N/A\r\n N/A\r\n Fast\r\n \r\n \r\n
\r\n\r\n### How It Works\r\n\r\n```tsx\r\n// You write familiar CSS-in-JS syntax\r\nconst example = \r\n\r\n// Devup UI transforms it at build time\r\nconst generated =
\r\n\r\n// With optimized atomic CSS\r\n// .a { background-color: red; }\r\n// .b { padding: 16px; } /* 4 * 4 = 16px */\r\n// .c:hover { background-color: blue; }\r\n```\r\n\r\n> Numeric values are multiplied by 4. `p={4}` becomes `padding: 16px`.\r\n\r\nClass names use compact base-37 encoding (`a`, `b`, ... `z`, `_`, `aa`, `ab`, ...) for minimal CSS output.\r\n\r\n### Familiar API\r\n\r\nIf you've used styled-components or Emotion, you'll feel right at home:\r\n\r\n```tsx\r\nimport { styled } from '@devup-ui/react'\r\n\r\nconst Card = styled('div', {\r\n bg: 'white',\r\n p: 4, // 4 * 4 = 16px\r\n borderRadius: '8px',\r\n boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',\r\n _hover: {\r\n boxShadow: '0 10px 15px rgba(0, 0, 0, 0.1)',\r\n },\r\n})\r\n```\r\n\r\n### Proven Performance\r\n\r\nBenchmarks on Next.js (GitHub Actions - ubuntu-latest):\r\n\r\n\r\n \r\n \r\n Library\r\n Version\r\n Build Time\r\n Build Size\r\n \r\n \r\n \r\n \r\n tailwindcss\r\n 4.1.13\r\n 19.31s\r\n 59,521,539 bytes\r\n \r\n \r\n styleX\r\n 0.15.4\r\n 41.78s\r\n 86,869,452 bytes\r\n \r\n \r\n vanilla-extract\r\n 1.17.4\r\n 19.50s\r\n 61,494,033 bytes\r\n \r\n \r\n kuma-ui\r\n 1.5.9\r\n 20.93s\r\n 69,924,179 bytes\r\n \r\n \r\n panda-css\r\n 1.3.1\r\n 20.64s\r\n 64,573,260 bytes\r\n \r\n \r\n chakra-ui\r\n 3.27.0\r\n 28.81s\r\n 222,435,802 bytes\r\n \r\n \r\n mui\r\n 7.3.2\r\n 20.86s\r\n 97,964,458 bytes\r\n \r\n \r\n **devup-ui (per-file css)**\r\n **1.0.18**\r\n **16.90s**\r\n 59,540,459 bytes\r\n \r\n \r\n **devup-ui (single css)**\r\n **1.0.18**\r\n **17.05s**\r\n **59,520,196 bytes**\r\n \r\n \r\n tailwindcss (turbopack)\r\n 4.1.13\r\n 6.72s\r\n 5,355,082 bytes\r\n \r\n \r\n **devup-ui (single css + turbopack)**\r\n **1.0.18**\r\n 10.34s\r\n **4,772,050 bytes**\r\n \r\n \r\n
\r\n\r\n### Get Started\r\n\r\nReady to experience the future of CSS-in-JS? Head to the [Installation](/docs/installation) guide to get started in minutes.\r\n","title":"What is Devup UI?","url":"/documentation/overview"},null,null,null,null] \ No newline at end of file +[{"text":"# vespera! Macro\n\nThe `vespera!()` macro is the entry point for every Vespera application. It scans your route folder at compile time, builds an `axum::Router` with all discovered handlers, and optionally writes an OpenAPI 3.1 spec file.\n\n## Full Parameter Reference\n\n```rust\nlet app = vespera!(\n dir = \"routes\", // Route folder (default: \"routes\")\n openapi = \"openapi.json\", // Output path (writes file at compile time)\n title = \"My API\", // OpenAPI info.title\n version = \"1.0.0\", // OpenAPI info.version (default: CARGO_PKG_VERSION)\n docs_url = \"/docs\", // Swagger UI endpoint\n redoc_url = \"/redoc\", // ReDoc endpoint\n servers = [ // OpenAPI servers array\n { url = \"https://api.example.com\", description = \"Production\" },\n { url = \"http://localhost:3000\", description = \"Development\" }\n ],\n merge = [crate1::App1, crate2::App2] // Merge child vespera apps\n);\n```\n\n## Environment Variable Fallbacks\n\nEvery parameter has a corresponding environment variable. The macro parameter takes priority over the env var, which takes priority over the built-in default.\n\n| Parameter | Environment Variable | Default |\n|-----------|---------------------|---------|\n| `dir` | `VESPERA_DIR` | `\"routes\"` |\n| `openapi` | `VESPERA_OPENAPI` | none |\n| `title` | `VESPERA_TITLE` | `\"API\"` |\n| `version` | `VESPERA_VERSION` | `CARGO_PKG_VERSION` |\n| `docs_url` | `VESPERA_DOCS_URL` | none |\n| `redoc_url` | `VESPERA_REDOC_URL` | none |\n| `servers` | `VESPERA_SERVER_URL` + `VESPERA_SERVER_DESCRIPTION` | none |\n\n## Common Patterns\n\n### Minimal — just a router\n\n```rust\nlet app = vespera!();\n```\n\n### With Swagger UI\n\n```rust\nlet app = vespera!(docs_url = \"/docs\");\n```\n\n### Write OpenAPI file + Swagger UI\n\n```rust\nlet app = vespera!(\n openapi = \"openapi.json\",\n docs_url = \"/docs\",\n title = \"My API\",\n version = \"1.0.0\"\n);\n```\n\n### Multiple OpenAPI output files\n\n```rust\nlet app = vespera!(\n openapi = [\"openapi.json\", \"docs/api-spec.json\"]\n);\n```\n\n### Custom route folder\n\n```rust\n// Scans src/api/ instead of src/routes/\nlet app = vespera!(dir = \"api\");\n```\n\n### With state and middleware\n\n```rust\nlet app = vespera!(docs_url = \"/docs\")\n .with_state(AppState { db: pool })\n .layer(CorsLayer::permissive())\n .layer(TraceLayer::new_for_http());\n```\n\n### Merging child apps\n\n```rust\nlet app = vespera!(\n openapi = \"openapi.json\",\n docs_url = \"/docs\",\n merge = [billing::BillingApp, notifications::NotificationsApp]\n)\n.with_state(app_state);\n```\n\n## The `.serve()` Extension\n\n`vespera!()` returns an `axum::Router`. Vespera adds a `.serve(addr)` extension trait that replaces the usual `TcpListener::bind` + `axum::serve(...)` boilerplate:\n\n```rust\nuse vespera::{vespera, Serve};\n\n#[tokio::main]\nasync fn main() -> std::io::Result<()> {\n vespera!(docs_url = \"/docs\")\n .serve(\"0.0.0.0:3000\")\n .await\n}\n```\n\n`addr` accepts anything `tokio::net::ToSocketAddrs` takes — strings like `\"0.0.0.0:3000\"`, tuples like `([0, 0, 0, 0], 3000)`, or a `SocketAddr`.\n\n## export_app! Macro\n\nExport a Vespera app from a library crate so it can be merged into a parent app:\n\n```rust\n// In the child crate's src/lib.rs\nmod routes;\n\n// Scans \"routes\" folder by default\nvespera::export_app!(MyApp);\n\n// Or with a custom directory\nvespera::export_app!(MyApp, dir = \"api\");\n```\n\nThis generates a struct with two associated items:\n- `MyApp::OPENAPI_SPEC: &'static str` — the OpenAPI JSON spec as a static string\n- `MyApp::router() -> Router` — a function returning the Axum router\n\nThe parent app merges it with `merge = [MyApp]` in `vespera!()`.\n","title":"vespera! Macro","url":"/documentation/api/api-1"},{"text":"# Route Attribute & Extractors\n\n`#[vespera::route]` marks a `pub async fn` as an HTTP handler. Vespera reads the function signature to extract path parameters, query parameters, request body, and response types for the OpenAPI spec.\n\n## Route Attribute Parameters\n\n```rust\n#[vespera::route(\n get, // HTTP method (default: get)\n path = \"/{id}\", // Path suffix (appended to file-based prefix)\n tags = [\"users\", \"admin\"], // OpenAPI tags\n description = \"Get user by ID\" // OpenAPI operation description\n)]\npub async fn get_user(Path(id): Path) -> Json { ... }\n```\n\n| Parameter | Type | Default | Description |\n|-----------|------|---------|-------------|\n| method | `get`, `post`, `put`, `patch`, `delete`, `head`, `options` | `get` | HTTP method |\n| `path` | string | `\"\"` | Path suffix appended to the file-based prefix |\n| `tags` | string array | `[]` | OpenAPI tags for grouping in Swagger UI |\n| `description` | string | `\"\"` | OpenAPI operation description |\n\n## Extractor to OpenAPI Mapping\n\nVespera reads your handler's extractor types and maps them to OpenAPI parameters and request bodies automatically:\n\n\n \n \n Extractor\n OpenAPI Location\n Notes\n \n \n \n \n `Path`\n Path parameters\n `T` can be a primitive or a struct\n \n \n `Query`\n Query parameters\n Struct fields become individual query params\n \n \n `Json`\n Request body (`application/json`)\n \n \n \n `Form`\n Request body (`application/x-www-form-urlencoded`)\n \n \n \n `TypedMultipart`\n Request body (`multipart/form-data`)\n Typed with schema\n \n \n `Multipart`\n Request body (`multipart/form-data`)\n Untyped, generic object\n \n \n `TypedHeader`\n Header parameters\n \n \n \n `State`\n Ignored\n Internal — not part of the API\n \n \n `Extension`\n Ignored\n Internal — not part of the API\n \n \n
\n\n## Examples\n\n### Path Parameters\n\n```rust\n// Single path param\n#[vespera::route(get, path = \"/{id}\")]\npub async fn get_user(Path(id): Path) -> Json { ... }\n\n// Multiple path params via struct\n#[derive(Deserialize)]\npub struct PostParams {\n pub user_id: u32,\n pub post_id: u32,\n}\n\n#[vespera::route(get, path = \"/{user_id}/posts/{post_id}\")]\npub async fn get_post(Path(params): Path) -> Json { ... }\n```\n\n### Query Parameters\n\n```rust\n#[derive(Deserialize, Schema)]\npub struct ListUsersQuery {\n pub page: Option,\n pub limit: Option,\n pub search: Option,\n}\n\n#[vespera::route(get)]\npub async fn list_users(Query(q): Query) -> Json> { ... }\n```\n\n### JSON Body\n\n```rust\n#[derive(Deserialize, Schema)]\npub struct CreateUserRequest {\n pub name: String,\n pub email: String,\n}\n\n#[vespera::route(post)]\npub async fn create_user(Json(req): Json) -> Json { ... }\n```\n\n### Validated Body (with 422)\n\n```rust\nuse vespera::Validated;\nuse garde::Validate;\n\n#[derive(Deserialize, Schema, Validate)]\npub struct CreateUserRequest {\n #[garde(length(min = 3, max = 32))]\n pub username: String,\n #[garde(email)]\n pub email: String,\n}\n\n#[vespera::route(post)]\npub async fn create_user(\n Validated(Json(req)): Validated>,\n) -> Json { ... }\n```\n\n### State (Ignored by OpenAPI)\n\n```rust\n#[vespera::route(get)]\npub async fn list_users(\n State(db): State, // ignored by OpenAPI\n Query(q): Query, // included in OpenAPI\n) -> Json> { ... }\n```\n\n### Error Responses\n\n```rust\n#[derive(Serialize, Schema)]\npub struct ApiError {\n pub message: String,\n}\n\n#[vespera::route(get, path = \"/{id}\")]\npub async fn get_user(\n Path(id): Path,\n) -> Result, (StatusCode, Json)> {\n if id == 0 {\n return Err((\n StatusCode::NOT_FOUND,\n Json(ApiError { message: \"Not found\".into() }),\n ));\n }\n Ok(Json(User { id, name: \"Alice\".into() }))\n}\n```\n\n## Handler Requirements\n\n- Must be `pub async fn` — private or non-async functions are ignored\n- Must have `#[vespera::route]` attribute\n- Can live anywhere in `src/routes/` (or your configured `dir`)\n- The URL is: **file path prefix + `path` attribute value**\n","title":"Route Attribute & Extractors","url":"/documentation/api/api-2"},{"text":"# schema_type!, schema!, and export_app!\n\n## schema_type! Macro\n\nGenerate request/response types from existing structs. Perfect for creating API DTOs from database models without duplicating field definitions.\n\n### Basic Usage\n\n```rust\nuse vespera::schema_type;\n\n// Include only specific fields\nschema_type!(CreateUserRequest from crate::models::user::Model, pick = [\"name\", \"email\"]);\n\n// Exclude specific fields\nschema_type!(UserResponse from crate::models::user::Model, omit = [\"password_hash\"]);\n\n// Add new fields (disables auto From impl)\nschema_type!(UpdateUserRequest from crate::models::user::Model, pick = [\"name\"], add = [(\"id\": i32)]);\n```\n\n### Auto-Generated From Impl\n\nWhen `add` is NOT used, a `From` impl is generated automatically:\n\n```rust\nschema_type!(UserResponse from crate::models::user::Model, omit = [\"password_hash\"]);\n\n// Use it directly:\nlet model: Model = db.find_user(id).await?;\nJson(model.into()) // From impl handles the conversion\n```\n\n### Same-File Model Reference\n\nWhen the model is in the same file, use a simple name with the `name` parameter:\n\n```rust\n// In src/models/user.rs\npub struct Model {\n pub id: i32,\n pub name: String,\n pub email: String,\n}\n\nvespera::schema_type!(Schema from Model, name = \"UserSchema\");\n```\n\n### Cross-File References\n\nReference structs from other files using full module paths:\n\n```rust\n// In src/routes/users.rs\nschema_type!(UserResponse from crate::models::user::Model, omit = [\"password_hash\"]);\n```\n\n### Partial Updates (PATCH)\n\n```rust\n// All fields become Option\nschema_type!(UserPatch from User, partial);\n\n// Only specific fields become Option\nschema_type!(UserPatch from User, partial = [\"name\", \"email\"]);\n```\n\n### Omit Database Defaults\n\n`omit_default` automatically omits fields with `#[sea_orm(primary_key)]` or `#[sea_orm(default_value = \"...\")]` — perfect for create DTOs:\n\n```rust\n#[derive(DeriveEntityModel)]\n#[sea_orm(table_name = \"posts\")]\npub struct Model {\n #[sea_orm(primary_key)] // omitted\n pub id: i32,\n pub title: String,\n pub content: String,\n #[sea_orm(default_value = \"NOW()\")] // omitted\n pub created_at: DateTimeWithTimeZone,\n}\n\n// Generated struct only has: title, content\nschema_type!(CreatePostRequest from crate::models::post::Model, omit_default);\n\n// Combine with add\nschema_type!(CreateItemRequest from Model, omit_default, add = [(\"tags\": Vec)]);\n```\n\n### Multipart Mode\n\nGenerate `Multipart` structs from existing types:\n\n```rust\n#[derive(vespera::Multipart, vespera::Schema)]\npub struct CreateUploadRequest {\n pub name: String,\n #[form_data(limit = \"10MiB\")]\n pub file: Option>,\n pub description: Option,\n}\n\n// Generates a Multipart struct (no serde derives), all fields Optional\nschema_type!(PatchUploadRequest from CreateUploadRequest, multipart, partial, omit = [\"file\"]);\n```\n\nWhen `multipart` is enabled:\n- Derives `Multipart` instead of `Serialize`/`Deserialize`\n- Preserves `#[form_data(...)]` attributes from the source struct\n- Skips SeaORM relation fields\n- Does not generate a `From` impl\n\n### Same-File Relation Adapters\n\nWhen a route file defines local response DTOs for SeaORM relations, `schema_type!` generates compile adapters so existing handler code stays valid:\n\n```rust\n#[derive(Serialize, vespera::Schema)]\n#[serde(rename_all = \"camelCase\")]\npub struct UserInArticle {\n pub id: Uuid,\n pub name: String,\n pub email: String,\n}\n\nschema_type!(\n ArticleResponse from crate::models::article::Model,\n add = [(\"review_users\": Vec)]\n);\n\n// Handler code unchanged:\nOk(ArticleResponse {\n user: user.into(), // adapter generated automatically\n review_users,\n ..\n})\n```\n\nThe naming convention is `{RelationNamePascal}In{ResponseBase}` — `user` on `ArticleResponse` → `UserInArticle`.\n\n### All Parameters\n\n| Parameter | Description |\n|-----------|-------------|\n| `pick` | Include only specified fields |\n| `omit` | Exclude specified fields |\n| `rename` | Rename fields: `rename = [(\"old\", \"new\")]` |\n| `add` | Add new fields (disables auto `From` impl) |\n| `clone` | Control Clone derive (default: `true`) |\n| `partial` | Make fields optional: `partial` or `partial = [\"field1\"]` |\n| `name` | Custom OpenAPI schema name (same-file references only) |\n| `rename_all` | Serde rename strategy: `rename_all = \"camelCase\"` |\n| `ignore` | Skip Schema derive (bare keyword) |\n| `multipart` | Derive `Multipart` instead of serde (bare keyword) |\n| `omit_default` | Auto-omit fields with DB defaults (bare keyword) |\n\n---\n\n## schema! Macro\n\nGet a `Schema` value at runtime with optional field filtering. Useful for programmatic schema access without generating a new struct type.\n\n```rust\nuse vespera::{Schema, schema};\n\n#[derive(Schema)]\npub struct User {\n pub id: i32,\n pub name: String,\n pub password: String,\n}\n\n// Full schema\nlet full: vespera::schema::Schema = schema!(User);\n\n// With fields omitted\nlet safe: vespera::schema::Schema = schema!(User, omit = [\"password\"]);\n\n// With only specified fields\nlet summary: vespera::schema::Schema = schema!(User, pick = [\"id\", \"name\"]);\n```\n\n> For creating request/response types with `From` impls, use `schema_type!` instead.\n\n---\n\n## export_app! Macro\n\nExport a Vespera app from a library crate for merging into a parent app. See [vespera! Macro](/documentation/api/api-1) for the merge usage.\n\n```rust\n// In the child crate's src/lib.rs\nmod routes;\n\n// Scans \"routes\" folder by default\nvespera::export_app!(MyApp);\n\n// Or with a custom directory\nvespera::export_app!(MyApp, dir = \"api\");\n```\n\nGenerates:\n- `MyApp::OPENAPI_SPEC: &'static str` — the OpenAPI JSON spec\n- `MyApp::router() -> Router` — the Axum router\n","title":"schema_type!, schema!, and export_app!","url":"/documentation/api/api-3"},{"text":"# API Reference\n\nComplete reference for Vespera's macros and attributes.\n\n## vespera! Macro\n\nThe entry point for every Vespera application. Scans your route folder at compile time, builds an `axum::Router`, and optionally writes an OpenAPI spec file.\n\nSee [vespera! Macro](/documentation/api/api-1) for the full parameter reference.\n\n## Route Attribute & Extractors\n\n`#[vespera::route]` marks a `pub async fn` as an HTTP handler. Vespera reads the function signature to extract path parameters, query parameters, request body, and response types for the OpenAPI spec.\n\nSee [Route Attribute & Extractors](/documentation/api/api-2) for all options and extractor mappings.\n\n## schema_type!, schema!, and export_app!\n\n- `schema_type!` — derive request/response DTOs from existing structs with `pick`, `omit`, `partial`, `add`, and SeaORM relation support\n- `schema!` — get a `Schema` value at runtime with optional field filtering\n- `export_app!` — export a Vespera app for merging into a parent app\n\nSee [schema_type! & More](/documentation/api/api-3) for the full reference.\n","title":"API Reference","url":"/documentation/api"},{"text":"# File-Based Routing\n\nVespera maps your `src/routes/` folder structure directly to URL paths. The `vespera!()` macro scans the folder at compile time — no manual `Router::new().route(...)` calls needed.\n\n## Folder to URL Mapping\n\n```\nsrc/routes/\n├── mod.rs → /\n├── users.rs → /users\n├── posts.rs → /posts\n└── admin/\n ├── mod.rs → /admin\n └── stats.rs → /admin/stats\n```\n\nThe final URL for a handler is: **file path prefix + `#[route]` path attribute**.\n\n```rust\n// In src/routes/users.rs\n#[vespera::route(get, path = \"/{id}\")]\npub async fn get_user(...) // → GET /users/{id}\n```\n\n## Handler Requirements\n\nHandlers must be `pub async fn`. Private or non-async functions are silently ignored by the scanner.\n\n```rust\n// Ignored — private\nasync fn get_users() -> Json> { ... }\n\n// Ignored — not async\npub fn get_users() -> Json> { ... }\n\n// Discovered\npub async fn get_users() -> Json> { ... }\n```\n\n## Route Attribute\n\n```rust\n// GET /users (default method is GET)\n#[vespera::route]\npub async fn list_users() -> Json> { ... }\n\n// POST /users\n#[vespera::route(post)]\npub async fn create_user(Json(user): Json) -> Json { ... }\n\n// GET /users/{id}\n#[vespera::route(get, path = \"/{id}\")]\npub async fn get_user(Path(id): Path) -> Json { ... }\n\n// PUT /users/{id} with tags and description\n#[vespera::route(put, path = \"/{id}\", tags = [\"users\"], description = \"Update user\")]\npub async fn update_user(...) -> ... { ... }\n```\n\n### Attribute Parameters\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| method | `get`, `post`, `put`, `patch`, `delete`, `head`, `options` | HTTP method (default: `get`) |\n| `path` | string | Path suffix appended to the file-based prefix |\n| `tags` | string array | OpenAPI tags for grouping in Swagger UI |\n| `description` | string | OpenAPI operation description |\n\n## Custom Route Folder\n\nThe default folder is `src/routes/`. Change it with the `dir` parameter or the `VESPERA_DIR` environment variable:\n\n```rust\n// Scans src/api/ instead of src/routes/\nlet app = vespera!(dir = \"api\");\n```\n\n## Error Handling\n\nReturn `Result` from handlers. Both `T` and `E` are included in the OpenAPI response schemas:\n\n```rust\n#[derive(Serialize, Schema)]\npub struct ApiError {\n pub message: String,\n}\n\n#[vespera::route(get, path = \"/{id}\")]\npub async fn get_user(\n Path(id): Path,\n) -> Result, (StatusCode, Json)> {\n if id == 0 {\n return Err((\n StatusCode::NOT_FOUND,\n Json(ApiError { message: \"Not found\".into() }),\n ));\n }\n Ok(Json(User { id, name: \"Alice\".into() }))\n}\n```\n","title":"File-Based Routing","url":"/documentation/concept/concept-1"},{"text":"# Schema & OpenAPI Generation\n\nVespera generates a complete OpenAPI 3.1 spec from your Rust types at compile time. Derive `Schema` on any type used in a handler's input or output and it appears in the spec automatically.\n\n## Deriving Schema\n\n```rust\n#[derive(Serialize, Deserialize, vespera::Schema)]\npub struct User {\n pub id: u32,\n pub name: String,\n pub email: String,\n pub bio: Option, // optional — not in `required` array\n}\n```\n\nVespera respects all standard serde attributes:\n\n```rust\n#[derive(Serialize, Deserialize, vespera::Schema)]\n#[serde(rename_all = \"camelCase\")]\npub struct CreateUserRequest {\n pub user_name: String, // → \"userName\" in OpenAPI\n pub email: String,\n\n #[serde(rename = \"fullName\")]\n pub name: String, // → \"fullName\" in OpenAPI\n\n #[serde(skip)]\n pub internal_id: u64, // excluded from schema\n\n pub bio: Option, // optional field\n}\n```\n\n## Type Mapping\n\n\n \n \n Rust Type\n OpenAPI Schema\n \n \n \n \n `String`, `&str`\n `string`\n \n \n `i8`–`i128`, `u8`–`u128`\n `integer`\n \n \n `f32`, `f64`\n `number`\n \n \n `bool`\n `boolean`\n \n \n `Vec`\n `array` with items\n \n \n `Option`\n T (parent marks field as optional)\n \n \n `HashMap`\n `object` with `additionalProperties`\n \n \n `BTreeSet`, `HashSet`\n `array` with `uniqueItems: true`\n \n \n `Uuid`\n `string` with `format: uuid`\n \n \n `Decimal`\n `string` with `format: decimal`\n \n \n `NaiveDate`\n `string` with `format: date`\n \n \n `NaiveTime`\n `string` with `format: time`\n \n \n `DateTime`, `DateTimeWithTimeZone`\n `string` with `format: date-time`\n \n \n `FieldData`\n `string` with `format: binary`\n \n \n `()`\n empty response (204 No Content)\n \n \n Custom struct\n `$ref` to `components/schemas`\n \n \n
\n\n## Generic Types\n\nAll type parameters must also derive `Schema`:\n\n```rust\n#[derive(Schema)]\nstruct Paginated {\n items: Vec,\n total: u32,\n page: u32,\n}\n```\n\n## SeaORM Integration\n\n`schema_type!` has first-class support for SeaORM models. Relation fields are converted automatically:\n\n```rust\n#[derive(Clone, Debug, DeriveEntityModel)]\n#[sea_orm(table_name = \"memos\")]\npub struct Model {\n #[sea_orm(primary_key)]\n pub id: i32,\n pub title: String,\n pub user_id: i32,\n pub user: BelongsTo, // → Option>\n pub comments: HasMany, // → Vec\n pub created_at: DateTimeWithTimeZone, // → chrono::DateTime\n}\n\nvespera::schema_type!(Schema from Model, name = \"MemoSchema\");\n```\n\n\n \n \n SeaORM Type\n Generated Schema Type\n \n \n \n \n `HasOne`\n `Box` or `Option>`\n \n \n `BelongsTo`\n `Option>`\n \n \n `HasMany`\n `Vec`\n \n \n `DateTimeWithTimeZone`\n `chrono::DateTime`\n \n \n
\n\nCircular references (e.g. User ↔ Memo) are detected automatically and handled by inlining fields to prevent infinite recursion.\n\n## Database Defaults in OpenAPI\n\nFields with SeaORM database defaults get `default` values in the generated schema:\n\n| SeaORM Attribute | OpenAPI Default |\n|-----------------|-----------------|\n| `primary_key` (Uuid) | `\"00000000-0000-0000-0000-000000000000\"` |\n| `primary_key` (i32/i64) | `0` |\n| `default_value = \"NOW()\"` | `\"1970-01-01T00:00:00+00:00\"` |\n| `default_value = \"gen_random_uuid()\"` | `\"00000000-0000-0000-0000-000000000000\"` |\n| `default_value = \"true\"` | `true` |\n\n> `required` is determined solely by nullability (`Option`). Fields with defaults are still `required` unless they are `Option`.\n\n## Configuring the OpenAPI Output\n\nPass parameters to `vespera!()` to control the spec:\n\n```rust\nlet app = vespera!(\n openapi = \"openapi.json\", // write spec to this file at compile time\n title = \"My API\",\n version = \"1.0.0\",\n docs_url = \"/docs\", // Swagger UI\n redoc_url = \"/redoc\", // ReDoc\n servers = [\n { url = \"https://api.example.com\", description = \"Production\" },\n { url = \"http://localhost:3000\", description = \"Development\" }\n ]\n);\n```\n\nSee [vespera! Macro](/documentation/api/api-1) for the full parameter reference.\n","title":"Schema & OpenAPI Generation","url":"/documentation/concept/concept-2"},{"text":"# `Validated` and 422\n\n`Validated` is a Vespera extractor wrapper that runs [`garde`](https://crates.io/crates/garde) validation **before** your handler is called. Invalid requests are rejected with `422 Unprocessable Entity` and a canonical JSON error envelope — no per-handler error mapping, no boilerplate.\n\n## Basic Usage\n\nAdd `garde` to your dependencies:\n\n```toml\n[dependencies]\nvespera = \"0.1\"\ngarde = { version = \"0.20\", features = [\"derive\"] }\n```\n\nAnnotate your request type with `garde` constraints and derive `Validate`:\n\n```rust\nuse vespera::{Validated, Schema, axum::Json};\nuse garde::Validate;\n\n#[derive(serde::Deserialize, Schema, Validate)]\npub struct CreateUser {\n #[garde(length(min = 3, max = 32))]\n pub username: String,\n #[garde(email)]\n pub email: String,\n #[garde(range(min = 18, max = 120))]\n pub age: u8,\n}\n\n#[vespera::route(post, tags = [\"users\"])]\npub async fn create_user(\n Validated(Json(req)): Validated>,\n) -> Json<&'static str> {\n // `req` has already passed garde validation — no manual checks needed.\n Json(\"ok\")\n}\n```\n\n## 422 Response Envelope\n\nWhen validation fails, Vespera returns `HTTP 422 Unprocessable Entity` with this JSON body:\n\n```json\n{\n \"errors\": [\n { \"path\": \"username\", \"message\": \"length is lower than 3\" },\n { \"path\": \"email\", \"message\": \"not a valid email\" }\n ]\n}\n```\n\nThe envelope is identical regardless of which extractor failed — your API clients only need to handle one error shape.\n\n## Supported Extractors\n\n`Validated` works with every common Axum extractor:\n\n\n \n \n Extractor\n Validates\n \n \n \n \n `Validated>`\n JSON request body\n \n \n `Validated>`\n URL-encoded form body\n \n \n `Validated>`\n URL query parameters\n \n \n `Validated>`\n Path parameters\n \n \n
\n\n## JNI Hoisting\n\nUnder JNI, the same `422` body is **hoisted** into the binary wire header as `\"validation_errors\": [...]`. Java decoders can read validation errors directly from the header without parsing the response body — no special-casing needed on the Java side.\n\n```json\n{\n \"v\": 1,\n \"status\": 422,\n \"headers\": { \"content-type\": \"application/json\" },\n \"validation_errors\": [\n { \"path\": \"username\", \"message\": \"length is lower than 3\" }\n ]\n}\n```\n\n## Common garde Constraints\n\n```rust\n#[derive(Deserialize, Schema, Validate)]\npub struct UpdateProfile {\n #[garde(length(min = 1, max = 100))]\n pub display_name: String,\n\n #[garde(url)]\n pub website: Option,\n\n #[garde(length(min = 8))]\n pub password: String,\n\n #[garde(range(min = 0.0, max = 5.0))]\n pub rating: f64,\n\n #[garde(inner(length(min = 1)))]\n pub tags: Vec,\n}\n```\n\nSee the [garde documentation](https://docs.rs/garde) for the full list of available constraints.\n","title":"`Validated` and 422","url":"/documentation/concept/concept-3"},{"text":"# Core Concepts\n\nVespera is built on three ideas: file-based routing, compile-time schema extraction, and automatic request validation.\n\n## File-Based Routing\n\nYour folder structure becomes your URL structure. Drop a `pub async fn` with `#[vespera::route]` anywhere in `src/routes/` and Vespera discovers it at compile time — no manual router registration.\n\n```\nsrc/routes/\n├── mod.rs → /\n├── users.rs → /users\n├── posts.rs → /posts\n└── admin/\n ├── mod.rs → /admin\n └── stats.rs → /admin/stats\n```\n\nSee [File-Based Routing](/documentation/concept/concept-1) for the full rules.\n\n## Schema & OpenAPI Generation\n\nDerive `Schema` on any Rust type and Vespera includes it in the generated OpenAPI 3.1 spec. Serde attributes (`rename_all`, `rename`, `skip`, `default`) are respected automatically.\n\n```rust\n#[derive(Serialize, Deserialize, vespera::Schema)]\n#[serde(rename_all = \"camelCase\")]\npub struct CreateUserRequest {\n pub user_name: String, // → \"userName\" in OpenAPI\n pub email: String,\n pub bio: Option, // optional field\n}\n```\n\nSee [Schema & OpenAPI](/documentation/concept/concept-2) for type mapping and SeaORM integration.\n\n## `Validated` and 422\n\nWrap any extractor in `Validated` to run `garde` validation before the handler runs. Invalid requests are rejected with `422 Unprocessable Entity` and a canonical JSON error envelope — no per-handler error mapping needed.\n\n```rust\n#[vespera::route(post)]\npub async fn create_user(\n Validated(Json(req)): Validated>,\n) -> Json<&'static str> {\n Json(\"ok\")\n}\n```\n\nSee [Validated & 422](/documentation/concept/concept-3) for the full contract.\n","title":"Core Concepts","url":"/documentation/concept"},{"text":"# Features\n\nBeyond routing and OpenAPI generation, Vespera ships several production-ready features that integrate with the same compile-time discovery system.\n\n## Cron Jobs\n\nSchedule background tasks with `#[vespera::cron]`. Jobs are auto-discovered like routes — no extra registration needed.\n\n### Enable the Feature\n\n```toml\n[dependencies]\nvespera = { version = \"0.1\", features = [\"cron\"] }\n```\n\n### Define Jobs\n\nPlace `#[vespera::cron(\"...\")]` on any `pub async fn` with zero parameters. The function can live anywhere in your project:\n\n```rust\n// src/cron/cleanup.rs, src/tasks.rs, or even src/routes/users.rs — anywhere works\n#[vespera::cron(\"1/10 * * * * *\")]\npub async fn cleanup_sessions() {\n println!(\"Running cleanup every 10 seconds\");\n}\n\n#[vespera::cron(\"0 0 * * * *\")]\npub async fn hourly_report() {\n println!(\"Running hourly report\");\n}\n```\n\nNo extra config in `vespera!()` — jobs are discovered and started automatically:\n\n```rust\nlet app = vespera!(docs_url = \"/docs\");\n// Background scheduler starts when the app starts\n```\n\n### Cron Expression Format\n\nUses 6-field cron expressions (`sec min hour day month weekday`):\n\n| Expression | Schedule |\n|-----------|----------|\n| `0 */5 * * * *` | Every 5 minutes |\n| `0 0 * * * *` | Every hour |\n| `0 0 0 * * *` | Daily at midnight |\n| `1/10 * * * * *` | Every 10 seconds |\n| `0 30 9 * * Mon-Fri` | Weekdays at 9:30 AM |\n\n### Requirements\n\n- Functions must be `pub async fn`\n- Functions must take **no parameters** (no `State`, no extractors)\n- The `cron` feature must be enabled in `Cargo.toml`\n\n---\n\n## Multipart Form Data\n\n### Typed Multipart (Recommended)\n\nUse `TypedMultipart` for file uploads with a statically-known schema. Vespera generates `multipart/form-data` content type in OpenAPI and maps `FieldData` to `{ \"type\": \"string\", \"format\": \"binary\" }`:\n\n```rust\nuse vespera::multipart::{FieldData, TypedMultipart};\nuse vespera::{Multipart, Schema};\nuse tempfile::NamedTempFile;\n\n#[derive(Multipart, Schema)]\npub struct CreateUploadRequest {\n pub name: String,\n #[form_data(limit = \"10MiB\")]\n pub file: Option>,\n}\n\n#[vespera::route(post, tags = [\"uploads\"])]\npub async fn create_upload(\n TypedMultipart(req): TypedMultipart,\n) -> Json { ... }\n```\n\n### Raw Multipart (Untyped)\n\nFor dynamic fields not known at compile time, use Axum's built-in `Multipart` extractor. Vespera generates a generic `{ \"type\": \"object\" }` schema:\n\n```rust\nuse vespera::axum::extract::Multipart;\n\n#[vespera::route(post, tags = [\"uploads\"])]\npub async fn upload(mut multipart: Multipart) -> Json {\n while let Some(field) = multipart.next_field().await.unwrap() {\n let name = field.name().unwrap_or(\"unknown\").to_string();\n let data = field.bytes().await.unwrap();\n // Process each field dynamically...\n }\n Json(UploadResponse { success: true })\n}\n```\n\n---\n\n## Merging Multiple Vespera Apps\n\nCombine routes and OpenAPI specs from multiple crates at compile time. Useful for splitting a large API into separate crates while presenting a single unified spec.\n\n### Export a Child App\n\n```rust\n// In the child crate's src/lib.rs\nmod routes;\n\n// Export for merging (scans \"routes\" folder by default)\nvespera::export_app!(ThirdApp);\n\n// Or with a custom directory\nvespera::export_app!(ThirdApp, dir = \"api\");\n```\n\nThis generates:\n- `ThirdApp::OPENAPI_SPEC: &'static str` — the child's OpenAPI JSON\n- `ThirdApp::router() -> Router` — the child's Axum router\n\n### Merge in the Parent App\n\n```rust\nuse vespera::vespera;\n\nlet app = vespera!(\n openapi = \"openapi.json\",\n docs_url = \"/docs\",\n merge = [third::ThirdApp, other::OtherApp]\n)\n.with_state(app_state);\n```\n\nVespera automatically:\n- Merges all child routes into the parent router\n- Combines OpenAPI specs (paths, schemas, tags) into a single document\n- Makes Swagger UI show all routes from all apps\n\n---\n\n## Multi-App Routing (JNI)\n\nWhen embedding Vespera in a Java/Spring application via JNI, you can register multiple independent apps and route between them per request.\n\n```rust\npub fn create_app() -> axum::Router { vespera!(title = \"Default\") }\npub fn admin_app() -> axum::Router { vespera!(dir = \"admin_routes\", title = \"Admin\") }\npub fn public_app() -> axum::Router { vespera!(dir = \"public_routes\", title = \"Public\") }\n\nvespera::jni_apps! {\n \"_default\" => create_app,\n \"admin\" => admin_app,\n \"public\" => public_app,\n}\n```\n\nThe Java side selects an app per request via the `X-Vespera-App` header (configurable):\n\n```bash\n# Default app (no header)\ncurl http://localhost:8080/health\n\n# Admin app\ncurl -H \"X-Vespera-App: admin\" http://localhost:8080/dashboard\n```\n\nSee [Streaming & Multi-App](/documentation/theme/theme-3) for the full multi-app routing reference.\n","title":"Features","url":"/documentation/features"},{"text":"# Installation\n\nGet Vespera running in your Axum project in under five minutes.\n\n## 1. Add Dependencies\n\n```toml\n[dependencies]\nvespera = \"0.1\"\naxum = \"0.8\"\ntokio = { version = \"1\", features = [\"full\"] }\nserde = { version = \"1\", features = [\"derive\"] }\n```\n\n> Vespera re-exports `axum` — use `vespera::axum` in your code instead of depending on `axum` directly. This keeps the version in sync automatically.\n\n## 2. Create Your First Route\n\nCreate the routes folder and add a handler:\n\n```\nsrc/\n├── main.rs\n└── routes/\n └── users.rs\n```\n\n**`src/routes/users.rs`**:\n\n```rust\nuse vespera::axum::{Json, extract::Path};\nuse serde::{Deserialize, Serialize};\nuse vespera::Schema;\n\n#[derive(Serialize, Deserialize, Schema)]\npub struct User {\n pub id: u32,\n pub name: String,\n}\n\n/// Get user by ID\n#[vespera::route(get, path = \"/{id}\", tags = [\"users\"])]\npub async fn get_user(Path(id): Path) -> Json {\n Json(User { id, name: \"Alice\".into() })\n}\n\n/// Create a new user\n#[vespera::route(post, tags = [\"users\"])]\npub async fn create_user(Json(user): Json) -> Json {\n Json(user)\n}\n```\n\n## 3. Set Up `main.rs`\n\n```rust\nuse vespera::{vespera, Serve};\n\n#[tokio::main]\nasync fn main() -> std::io::Result<()> {\n println!(\"Swagger UI: http://localhost:3000/docs\");\n vespera!(\n openapi = \"openapi.json\",\n title = \"My API\",\n docs_url = \"/docs\"\n )\n .serve(\"0.0.0.0:3000\")\n .await\n}\n```\n\n`.serve(addr)` is a Vespera extension trait on `axum::Router`. It replaces the usual `TcpListener::bind` + `axum::serve(...)` dance with a single chained call. `addr` accepts anything `tokio::net::ToSocketAddrs` takes — strings, tuples, or `SocketAddr`.\n\n## 4. Run\n\n```bash\ncargo run\n# Open http://localhost:3000/docs\n```\n\nYour Swagger UI is live. The `openapi.json` file is written to the project root at compile time.\n\n## Adding State and Middleware\n\nChain standard Axum methods after `vespera!()`:\n\n```rust\nlet app = vespera!(docs_url = \"/docs\")\n .with_state(AppState { db: pool })\n .layer(CorsLayer::permissive())\n .layer(TraceLayer::new_for_http());\n```\n\n## JNI / Java Integration\n\nTo embed Vespera inside a Java/Spring application, enable the `jni` feature:\n\n```toml\n[dependencies]\nvespera = { version = \"0.1\", features = [\"jni\"] }\n```\n\nThen add two lines to your Rust lib:\n\n```rust\npub fn create_app() -> vespera::axum::Router {\n vespera!(title = \"My API\")\n}\n\nvespera::jni_app!(create_app);\n```\n\nSee the [JNI / Java Integration](/documentation/theme) section for the full setup guide.\n\n## Cron Jobs\n\nEnable the `cron` feature to schedule background tasks:\n\n```toml\n[dependencies]\nvespera = { version = \"0.1\", features = [\"cron\"] }\n```\n\nSee [Features](/documentation/features) for usage details.\n","title":"Installation","url":"/documentation/installation"},{"text":"# What is Vespera?\n\n**FastAPI-like developer experience for Rust.** Zero-config OpenAPI 3.1 generation for Axum.\n\n```rust\n// That's it. Swagger UI at /docs, OpenAPI at openapi.json\nlet app = vespera!(openapi = \"openapi.json\", docs_url = \"/docs\");\n```\n\nVespera scans your `src/routes/` folder at compile time, extracts every `#[vespera::route]` handler and `#[derive(Schema)]` type, and assembles a complete OpenAPI 3.1 spec — no annotations to maintain, no runtime reflection, no hand-written JSON.\n\n## Why Vespera?\n\n\n \n \n Feature\n Vespera\n Manual Approach\n \n \n \n \n Route registration\n Automatic (file-based)\n Manual `Router::new().route(...)`\n \n \n OpenAPI spec\n Generated at compile time\n Hand-written or runtime generation\n \n \n Schema extraction\n `#[derive(Schema)]` on Rust types\n Manual JSON Schema\n \n \n Request validation\n `Validated` extractor → auto `422`\n Manual checks in every handler\n \n \n Server startup\n `.serve(\"0.0.0.0:3000\")` one-liner\n `TcpListener::bind` + `axum::serve`\n \n \n Swagger UI\n Built-in\n Separate setup\n \n \n Type safety\n Compile-time verified\n Runtime errors\n \n \n
\n\n## Headline Capabilities\n\n\n \n \n Capability\n How\n \n \n \n \n `#[derive(Schema)]` → OpenAPI 3.1\n Rust types become JSON Schema at compile time, including serde renames, `Option`, `Vec`, SeaORM relations\n \n \n `Validated` extractor + auto-`422`\n Wraps `Json`/`Form`/`Query`/`Path` and runs `garde::Validate` before the handler — rejection is `422` with a canonical JSON envelope\n \n \n `schema_type! { ... }`\n Derive request/response DTOs from existing structs (`pick` / `omit` / `partial` / `add` / `multipart` / `omit_default`) with first-class SeaORM relation support\n \n \n One-liner `.serve(addr)`\n Extension trait on `axum::Router` — replaces `TcpListener::bind` + `axum::serve` boilerplate\n \n \n JNI / Spring integration\n Embed your Axum router inside a Java/Spring app in-process — no TCP, no base64, raw bytes end to end\n \n \n Cron jobs\n `#[vespera::cron(\"...\")]` — auto-discovered like routes, runs via `tokio-cron-scheduler`\n \n \n
\n\n## JNI Performance Numbers\n\nWhen embedding Vespera inside a Java/Spring application via JNI, the `SmartDispatchModeResolver` (default since vespera-bridge 0.2.0) picks the cheapest safe path per request. Measured on a `GET /health` round-trip through the real JNI boundary (AMD Ryzen 9 9950X, Java 21, Windows 11):\n\n\n \n \n Request shape\n Mode\n ns / round-trip\n \n \n \n \n Small/bodyless + idempotent (GET/HEAD/PUT/DELETE/OPTIONS, ≤ 256 KiB)\n `DIRECT` (pooled direct buffers)\n ~2,200 ns\n \n \n Small (≤ 256 KiB) + non-idempotent (POST/PATCH)\n `SYNC` (heap-buffered)\n ~3,200 ns\n \n \n Large or unknown-length body\n `BIDIRECTIONAL_STREAMING`\n ~24,100 ns\n \n \n
\n\nBinary streaming throughput (64 MiB payload, bidirectional):\n\n\n \n \n Chunk size\n Throughput\n \n \n \n \n 16 KiB\n ~10,408 MiB/s\n \n \n 64 KiB\n ~11,587 MiB/s\n \n \n 256 KiB\n ~14,458 MiB/s\n \n \n
\n\nThe `direct_pooled` path completes a tiny `/health` round-trip in **2,349 ns/op** — **1.55× faster** than the pre-0.2.0 sync baseline (3,643 ns/op).\n\n## How It Works\n\n```\nsrc/routes/\n├── mod.rs → /\n├── users.rs → /users\n└── admin/\n └── stats.rs → /admin/stats\n```\n\n1. You place `pub async fn` handlers in `src/routes/` and annotate them with `#[vespera::route]`.\n2. The `vespera!()` macro scans the folder at compile time, discovers every handler, and builds an `axum::Router`.\n3. Types annotated with `#[derive(Schema)]` are extracted into OpenAPI component schemas automatically.\n4. The generated `openapi.json` and Swagger UI are served at the URLs you configure.\n\n## Get Started\n\nHead to [Installation](/documentation/installation) to add Vespera to your project in under five minutes.\n","title":"What is Vespera?","url":"/documentation/overview"},{"text":"# JNI / Java Integration\n\nVespera can embed your Axum router directly inside a Java/Spring application — no TCP socket, no JSON envelope overhead, raw bytes from end to end.\n\nThe `vespera-bridge` library (`kr.devfive:vespera-bridge`) provides a Spring Boot autoconfiguration that wires up a catch-all `VesperaProxyController`. Every HTTP request Spring receives is forwarded to Rust through a length-prefixed binary wire format, and the response comes back the same way.\n\n## Why In-Process?\n\nA traditional microservice setup adds a full HTTP round-trip between Java and Rust. In-process JNI dispatch eliminates that entirely:\n\n- No TCP connection overhead\n- No JSON serialization of the envelope\n- Binary bodies (multipart, PDFs, images) travel as raw bytes — no base64\n- Measured latency for small requests: **~2,200 ns** with the `DIRECT` dispatch mode\n\n## Quick Navigation\n\n- [jni_app! & VesperaBridge](/documentation/theme/theme-1) — Rust setup, Java setup, native library loading\n- [Dispatch Modes & Wire Format](/documentation/theme/theme-2) — all seven dispatch methods, binary wire layout, `SmartDispatchModeResolver` defaults\n- [Streaming & Multi-App](/documentation/theme/theme-3) — streaming tuning, multi-app routing, virtual thread notes, 0.2.0 breaking changes\n\n## Two-Line Integration\n\n**Rust side:**\n\n```rust\npub fn create_app() -> vespera::axum::Router {\n vespera!(title = \"My API\")\n}\n\nvespera::jni_app!(create_app);\n```\n\n**Java side:**\n\n```java\n@SpringBootApplication\n@ComponentScan(basePackages = {\"com.example.app\", \"com.devfive.vespera.bridge\"})\npublic class MyApp {\n public static void main(String[] args) {\n VesperaBridge.init(\"my_rust_lib\");\n SpringApplication.run(MyApp.class, args);\n }\n}\n```\n\nThat's it. `VesperaProxyController` is autoconfigured and forwards every HTTP request to Rust. Zero controller code, zero `application.yml` config, zero extra imports beyond the Spring Boot starter.\n","title":"JNI / Java Integration","url":"/documentation/theme"},{"text":"# jni_app! & VesperaBridge\n\n## Rust Setup\n\n### 1. Enable the JNI Feature\n\n```toml\n[dependencies]\nvespera = { version = \"0.1\", features = [\"jni\"] }\n```\n\nThe `jni` feature implies `inprocess` — both are enabled automatically.\n\n### 2. Export Your App\n\nIn your cdylib crate's `src/lib.rs`:\n\n```rust\nuse vespera::{axum, vespera};\n\npub fn create_app() -> axum::Router {\n vespera!(title = \"My API\", version = \"1.0.0\")\n}\n\n// Single app — generates JNI_OnLoad and the dispatch symbol\nvespera::jni_app!(create_app);\n```\n\n`jni_app!` generates all JNI boilerplate: `JNI_OnLoad`, the Tokio runtime, and the seven dispatch symbols. You write zero JNI code.\n\n### 3. Build as a cdylib\n\n```toml\n[lib]\ncrate-type = [\"cdylib\"]\n```\n\n```bash\ncargo build --release\n# Produces: target/release/libmy_rust_lib.so (Linux)\n# target/release/my_rust_lib.dll (Windows)\n# target/release/libmy_rust_lib.dylib (macOS)\n```\n\n---\n\n## Java Setup\n\n### Maven\n\n```xml\n\n kr.devfive\n vespera-bridge\n 0.2.0\n\n```\n\n### Gradle (Kotlin DSL)\n\n```kotlin\ndependencies {\n implementation(\"kr.devfive:vespera-bridge:0.2.0\")\n}\n```\n\n### Gradle Plugin (Recommended)\n\nThe `kr.devfive.vespera-bridge` Gradle plugin replaces ~22 lines of native-library-bundling boilerplate with a 5-line block:\n\n```kotlin\nplugins {\n id(\"kr.devfive.vespera-bridge\") version \"0.1.1\"\n}\n\nvespera {\n crateName.set(\"my_rust_lib\")\n cargoRoot.set(rootProject.layout.projectDirectory.dir(\"../..\"))\n bridgeVersion.set(\"0.2.0\")\n}\n```\n\nThe plugin auto-wires `bundleNativeLib` (cdylib → `resources/native/-/`), the `processResources` dependency, and the `vespera-bridge` implementation dependency.\n\n### Spring Boot Application\n\n```java\n@SpringBootApplication\n@ComponentScan(basePackages = {\"com.example.app\", \"com.devfive.vespera.bridge\"})\npublic class MyApp {\n public static void main(String[] args) {\n VesperaBridge.init(\"my_rust_lib\"); // loads cdylib (bundled or system path)\n SpringApplication.run(MyApp.class, args);\n }\n}\n```\n\n`VesperaProxyController` is autoconfigured via Spring Boot's `AutoConfiguration.imports`. It registers a `@RequestMapping(\"/**\")` catch-all that forwards every HTTP request to Rust. The routes published in Vespera's generated `openapi.json` are reachable at the same URLs through Spring.\n\n---\n\n## Native Library Loading\n\n`VesperaBridge.init(\"crateName\")` tries two paths in order:\n\n1. **Bundled** — looks up `native/{os}-{arch}/{libname}` inside the running JAR's classpath. If found, the file is extracted to a temp file (auto-deleted on JVM exit) and loaded via `System.load`.\n2. **Fallback** — `System.loadLibrary(\"crateName\")` searches `java.library.path`.\n\nSupported platform triples: `linux-x86_64`, `linux-aarch64`, `macos-x86_64`, `macos-aarch64`, `windows-x86_64`.\n\nPlace the cdylib at `src/main/resources/native/{os}-{arch}/` to bundle it inside the JAR for single-file deployment.\n\n---\n\n## Zero-Config Defaults\n\nOut of the box the autoconfigure module wires up:\n\n| Concern | Default | Override |\n|---------|---------|----------|\n| App selection | Read `X-Vespera-App` request header; absent → default app | Property `vespera.bridge.app-header`, or custom `AppNameResolver` bean |\n| Dispatch mode | `SmartDispatchModeResolver` since 0.2.0 — `DIRECT` for small/bodyless idempotent, `SYNC` for small non-idempotent, `BIDIRECTIONAL_STREAMING` for the rest | Property `vespera.bridge.dispatch-mode: bidirectional-streaming`, or custom `DispatchModeResolver` bean |\n| URL pattern | `@RequestMapping(\"/**\")` catch-all | Set `vespera.bridge.controller-enabled: false` and supply your own controller |\n\n---\n\n## Customization\n\n### Tweak via application.yml\n\n```yaml\nvespera:\n bridge:\n app-header: X-My-App # change the header that selects the app\n controller-enabled: true # set false to disable the proxy controller\n```\n\n### Custom App-Selection Strategy\n\n```java\n@Bean\npublic AppNameResolver myAppResolver() {\n return request -> {\n String uri = request.getRequestURI();\n if (uri.startsWith(\"/admin/\")) return \"admin\";\n if (uri.startsWith(\"/public/\")) return \"public\";\n return null; // default app\n };\n}\n```\n\nSpring's `@ConditionalOnMissingBean` automatically disables `HeaderAppNameResolver` when you supply your own bean.\n\n### Custom Dispatch-Mode Policy\n\n```java\n@Bean\npublic DispatchModeResolver myModeResolver() {\n return request -> {\n long contentLength = request.getContentLengthLong();\n if (contentLength >= 0 && contentLength < 4096\n && \"application/json\".equals(request.getContentType())) {\n return DispatchMode.SYNC;\n }\n return DispatchMode.BIDIRECTIONAL_STREAMING;\n };\n}\n```\n\n### BYO Controller\n\n```yaml\nvespera:\n bridge:\n controller-enabled: false\n```\n\n```java\n@RestController\npublic class MyController {\n @PostMapping(\"/api/admin/{path}\")\n public ResponseEntity adminRoute(@PathVariable String path, @RequestBody byte[] body) {\n byte[] wire = VesperaBridge.encodeRequest(\n \"admin\", \"POST\", \"/\" + path, null,\n Map.of(\"content-type\", \"application/json\"), body);\n byte[] resp = VesperaBridge.dispatchBytes(wire);\n DecodedResponse d = VesperaBridge.decodeResponse(resp);\n return ResponseEntity.status(d.status()).body(d.bodyBytes());\n }\n}\n```\n","title":"jni_app! & VesperaBridge","url":"/documentation/theme/theme-1"},{"text":"# Dispatch Modes & Wire Format\n\n## Binary Wire Format\n\nBoth request and response use the same length-prefixed layout:\n\n```\nbytes 0..4 : u32 BE = header_json byte length N\nbytes 4..4+N : UTF-8 JSON\n (request) { \"v\":1, \"method\", \"path\",\n \"query\"?, \"headers\"? }\n (response) { \"v\":1, \"status\", \"headers\",\n \"metadata\", \"validation_errors\"? }\nbytes 4+N.. : raw body bytes (UTF-8 text or binary —\n no encoding applied)\n```\n\nKey properties:\n- No base64 — multipart uploads, PDFs, and images travel as raw bytes\n- `\"v\":1` is the protocol version; mismatched versions return a `400` wire response\n- `\"validation_errors\"` is an optional array hoisted from `422` JSON bodies — Java decoders read validation errors from the header without parsing the body\n- All failure paths (malformed wire, Rust panic, no app registered) return a valid length-prefixed response, so the decoder never has to special-case errors\n\n## Dispatch Modes\n\n`VesperaBridge` exposes seven native methods — all sharing the same wire format, the same registered router, and the same panic-safe `catch_unwind` discipline:\n\n\n \n \n Method\n Mode\n Java return\n Memory\n \n \n \n \n `dispatchBytes(byte[])`\n sync\n `byte[]` (header + body)\n full body in memory\n \n \n `dispatchAsync(CompletableFuture, byte[])`\n async\n `void` (future completes)\n full body in memory\n \n \n `dispatchStreaming(byte[], OutputStream)`\n sync, response-streaming\n `byte[]` (header only)\n chunk-bounded response\n \n \n `dispatchFullStreaming(byte[], InputStream, OutputStream)`\n sync, bidirectional streaming\n `byte[]` (header only)\n chunk-bounded both ways\n \n \n `dispatchStreamingWithHeader(byte[], Consumer, OutputStream)`\n sync, response-streaming\n `void` (header via callback)\n chunk-bounded response\n \n \n `dispatchFullStreamingWithHeader(byte[], Consumer, InputStream, OutputStream)`\n sync, bidirectional streaming\n `void` (header via callback)\n chunk-bounded both ways\n \n \n `dispatchDirect(ByteBuffer, int, ByteBuffer)`\n sync, direct buffers\n `int` (response length / overflow code)\n no Java heap arrays\n \n \n
\n\n### Choosing a Mode\n\n- Small JSON RPC, single request/response → `dispatchBytes`\n- Hot small/bounded payloads where JNI copy overhead matters → `dispatchDirect` / `dispatchDirectPooled`\n- Async I/O coordination (parallel Java requests, non-blocking) → `dispatchAsync` + `CompletableFuture`\n- Large download / streaming response (video, PDF, SSE) → `dispatchStreaming` + `OutputStream`\n- Large upload + large download (file transfer, video transcoding) → `dispatchFullStreaming` + `InputStream` + `OutputStream`\n- The `*WithHeader` variants let Spring-style controllers commit status/headers before the first body byte is written\n\n## SmartDispatchModeResolver (Default since 0.2.0)\n\nThe autoconfigured default since vespera-bridge 0.2.0 picks the cheapest safe path per request. Measured on a `GET /health` round-trip through the real JNI boundary:\n\n| Request shape | Mode | ns / round-trip |\n|---------------|------|-----------------|\n| Small/bodyless + idempotent (GET/HEAD/PUT/DELETE/OPTIONS, ≤ 256 KiB) | `DIRECT` | ~2,200 |\n| Small (≤ 256 KiB Content-Length) + non-idempotent (POST/PATCH) | `SYNC` | ~3,200 |\n| Large or unknown-length body | `BIDIRECTIONAL_STREAMING` | ~24,100 |\n\nTrade-offs:\n- **DIRECT** writes the wire response straight into a pooled per-thread direct `ByteBuffer` (64 KiB → `vespera.direct.maxBufferBytes`, default 4 MiB). Responses larger than the pooled buffer trigger a single retry that **re-runs the Rust handler** — which is why DIRECT is gated on idempotent methods only.\n- **SYNC** fully buffers the response on the JVM heap. The 256 KiB request-size gate keeps the response size reasonable for JSON-RPC-shaped traffic.\n- **BIDIRECTIONAL_STREAMING** is unchanged for large/unknown-length bodies — multi-GB upload + multi-GB download runs chunk-bounded, ~32 KiB resident each side.\n\nRestore the pre-0.2.0 default (every request that may carry a body streams both ways, ~24 µs uniform):\n\n```yaml\nvespera:\n bridge:\n dispatch-mode: bidirectional-streaming\n```\n\n## Direct Buffer Dispatch\n\n`dispatchDirect(ByteBuffer in, int inLen, ByteBuffer out)` eliminates the two JNI `GetByteArrayRegion`/`SetByteArrayRegion` copies that `dispatchBytes` pays. The response is streamed straight into the out buffer — no intermediate `Vec`. Measured at **1.4–3.4× per round-trip** versus `dispatchBytes` depending on payload size.\n\nContract:\n- Both buffers MUST be direct (`ByteBuffer.allocateDirect`); heap buffers are rejected with `IllegalArgumentException`\n- The request is read from absolute offsets `in[0..inLen]` — the buffer's position/limit are ignored; `inLen` is authoritative\n- Return `>= 0`: a complete wire response occupies `out[0..n]`\n- Return `< 0`: `-(requiredSize)` — the response did not fit; **retrying re-runs the Rust handler**, so only retry idempotent requests\n- `Integer.MIN_VALUE`: response exceeds 2 GiB\n\n`dispatchDirectPooled(byte[] wireRequest, boolean retryOnOverflow)` wraps the raw call with per-thread reusable direct buffers (64 KiB initial, doubling up to `vespera.direct.maxBufferBytes`, default 4 MiB).\n\n## Direct API (Without the Proxy Controller)\n\n```java\nimport com.devfive.vespera.bridge.VesperaBridge;\nimport com.devfive.vespera.bridge.VesperaBridge.DecodedResponse;\n\n// 1. Initialise once at startup\nVesperaBridge.init(\"my_rust_lib\");\n\n// 2. Encode a request\nbyte[] wireRequest = VesperaBridge.encodeRequest(\n \"POST\",\n \"/documents/validate\",\n /* query */ null,\n Map.of(\"content-type\", \"application/json\"),\n \"{\\\"title\\\":\\\"…\\\"}\".getBytes(StandardCharsets.UTF_8));\n\n// 3. Dispatch through Rust\nbyte[] wireResponse = VesperaBridge.dispatchBytes(wireRequest);\n\n// 4. Decode\nDecodedResponse resp = VesperaBridge.decodeResponse(wireResponse);\nSystem.out.println(resp.status()); // 200\nSystem.out.println(resp.headers()); // { \"content-type\": \"application/json\", … }\nSystem.out.println(new String(resp.bodyBytes())); // copies the raw response body\n```\n\n> **0.2.0 breaking change:** `DecodedResponse.body()` now returns a read-only `java.nio.ByteBuffer` (zero-copy view over the wire bytes). The owned `byte[]` materialisation moved to `DecodedResponse.bodyBytes()`. Callers that previously used `body()` as `byte[]` must switch to `bodyBytes()`.\n\n## Async Dispatch\n\n```java\nCompletableFuture future = VesperaBridge.dispatch(wireRequest);\n\nfuture.thenAccept(wireResponse -> {\n DecodedResponse resp = VesperaBridge.decodeResponse(wireResponse);\n System.out.println(\"Status: \" + resp.status());\n});\n```\n\nThe future is **always** completed with a valid wire response, even on Rust panics or JNI conversion failures. You will never see a dangling future.\n\n## Streaming Dispatch\n\n```java\nbyte[] wireRequest = VesperaBridge.encodeRequest(\n \"GET\", \"/files/large.pdf\", null, Map.of(), new byte[0]);\n\ntry (ByteArrayOutputStream sink = new ByteArrayOutputStream()) {\n byte[] headerOnly = VesperaBridge.dispatchStreaming(wireRequest, sink);\n DecodedResponse meta = VesperaBridge.decodeResponse(headerOnly);\n System.out.println(\"Status: \" + meta.status());\n System.out.println(\"Body size: \" + sink.size());\n}\n```\n\n## Bidirectional Streaming\n\n```java\ntry (InputStream upload = Files.newInputStream(Path.of(\"huge.mp4\"));\n OutputStream download = Files.newOutputStream(Path.of(\"transcoded.mp4\"))) {\n\n byte[] wireHeader = VesperaBridge.encodeRequestHeader(\n \"POST\", \"/transcode\", null,\n Map.of(\"content-type\", \"video/mp4\"));\n\n byte[] respHeader = VesperaBridge.dispatchFullStreaming(\n wireHeader, upload, download);\n\n DecodedResponse meta = VesperaBridge.decodeResponse(respHeader);\n System.out.println(\"Status: \" + meta.status());\n}\n```\n\nA 1 GiB upload paired with a 1 GiB download runs in low-single-digit MiB resident memory on each side. Backpressure is enforced naturally — if Axum reads slowly, `InputStream.read()` blocks on the bounded channel.\n","title":"Dispatch Modes & Wire Format","url":"/documentation/theme/theme-2"},{"text":"# Streaming & Multi-App\n\n## Streaming Tuning\n\nBoth streaming knobs are fixed for the process lifetime once the first dispatch runs. Configuration precedence (first hit wins):\n\n1. **Programmatic setter** — `VesperaBridge.configureStreaming(chunkBytes, channelCapacity)` (call before or after `init`)\n2. **System properties** — `vespera.streaming.chunkBytes`, `vespera.streaming.channelCapacity`\n3. **Environment variables** — `VESPERA_STREAMING_CHUNK_BYTES`, `VESPERA_STREAMING_CHANNEL_CAPACITY`\n4. **Built-in defaults** — 256 KiB chunk size, 16 channel slots\n\n| Setting | System property | Env var | Default | Range |\n|---------|----------------|---------|---------|-------|\n| Chunk buffer size | `vespera.streaming.chunkBytes` | `VESPERA_STREAMING_CHUNK_BYTES` | 256 KiB | 4 KiB – 8 MiB |\n| Request channel slots | `vespera.streaming.channelCapacity` | `VESPERA_STREAMING_CHANNEL_CAPACITY` | 16 | 1 – 1024 |\n| Tokio worker threads | `vespera.runtime.workerThreads` | `VESPERA_RUNTIME_WORKERS` | logical CPUs | 1 – 1024 |\n\n### Java API\n\nCall before `VesperaBridge.init(...)` for guaranteed precedence:\n\n```java\nVesperaBridge.configureStreaming(\n 131072, // chunkBytes: 128 KiB (clamped to 4 KiB – 8 MiB)\n 32 // channelCapacity: 32 slots (clamped to 1 – 1024)\n);\nVesperaBridge.init(\"my_rust_lib\");\n```\n\nWhen called before `init()`, values are stored as pending and applied immediately after the native library loads — before any dispatch can occur. This ensures the programmatic setter beats system properties and environment variables.\n\nThrows `IllegalArgumentException` if `chunkBytes` is outside `[4096, 8388608]` or `channelCapacity` is outside `[1, 1024]`.\n\n### System Properties\n\n```bash\njava -Dvespera.streaming.chunkBytes=131072 \\\n -Dvespera.streaming.channelCapacity=32 \\\n -jar app.jar\n```\n\n### Environment Variables\n\n```bash\nexport VESPERA_STREAMING_CHUNK_BYTES=131072\nexport VESPERA_STREAMING_CHANNEL_CAPACITY=32\njava -jar app.jar\n```\n\n### Tuning Tips\n\n- Larger chunks reduce the per-chunk JNI crossing cost (one `SetByteArrayRegion` + one `OutputStream.write` per chunk) at the price of per-stream memory. 256 KiB is a reasonable ceiling for throughput-oriented deployments.\n- The Tokio worker-thread knob caps Rust's shared runtime — useful when the JVM's own pools (Tomcat request threads, virtual-thread carriers) compete with Tokio for the same cores, or when a container CPU limit is lower than the host's logical CPU count.\n\n---\n\n## Multi-App Routing\n\nMulti-app routing is primarily a feature for external-dispatcher scenarios — JNI (Java host picks app per request via header), WebAssembly bridge, C FFI, or any in-process embedding where the host distinguishes between multiple independent Vespera API surfaces.\n\n### Rust Side\n\n```rust\npub fn create_app() -> axum::Router { vespera!(title = \"Default\") }\npub fn admin_app() -> axum::Router { vespera!(dir = \"admin_routes\", title = \"Admin\") }\npub fn public_app() -> axum::Router { vespera!(dir = \"public_routes\", title = \"Public\") }\n\nvespera::jni_apps! {\n \"_default\" => create_app,\n \"admin\" => admin_app,\n \"public\" => public_app,\n}\n```\n\n`jni_apps!` is the primary multi-app API. `jni_app!(create_app)` is syntactic sugar for a single default app.\n\n### Java Side\n\nThe default `HeaderAppNameResolver` selects an app per request via the `X-Vespera-App` header:\n\n```bash\n# Default app (no header)\ncurl http://localhost:8080/health\n\n# Admin app\ncurl -H \"X-Vespera-App: admin\" http://localhost:8080/dashboard\n\n# Public app\ncurl -H \"X-Vespera-App: public\" http://localhost:8080/info\n```\n\nEach app's URLs are independent — the same `/users` path can mean different things in `admin` vs `public` apps. Unknown app names return `404`; invalid app names (special characters, > 64 bytes) return `400`.\n\n### Custom App-Selection Strategy\n\n```java\n@Bean\npublic AppNameResolver myAppResolver() {\n // App name from the first path segment:\n // /admin/dashboard → app \"admin\", path \"/dashboard\"\n // /public/info → app \"public\", path \"/info\"\n return request -> {\n String uri = request.getRequestURI();\n if (uri.startsWith(\"/admin/\")) return \"admin\";\n if (uri.startsWith(\"/public/\")) return \"public\";\n return null; // default app\n };\n}\n```\n\n---\n\n## Virtual Thread (Project Loom) Limitation\n\nThe pooled direct-buffer methods (`dispatchDirectPooled`) use `ThreadLocal` to maintain per-thread reusable buffers. In Java 21+, `ThreadLocal` binds to the **virtual thread** (not the carrier thread) — so in a virtual-thread-per-request server, each virtual thread allocates a fresh direct buffer and loses all pooling benefit. Direct memory accumulates until the virtual thread is garbage-collected, potentially causing memory pressure under high concurrency.\n\n**Recommendations for virtual-thread deployments:**\n\n- Set `vespera.bridge.dispatch-mode=bidirectional-streaming` to opt out of the smart default, so `DIRECT` is never chosen by the autoconfigured resolver.\n- Or use `dispatchBytes`, `dispatchStreaming`, or `dispatchFullStreaming` directly instead of the pooled direct variants.\n- Or run dispatch on a bounded platform-thread executor (e.g. a `ForkJoinPool` with a fixed parallelism cap).\n- Or lower `vespera.direct.maxBufferBytes` to reduce per-thread allocation size.\n\n`DispatchMode.BIDIRECTIONAL_STREAMING` is safe for virtual threads and handles all payload sizes without pooling.\n\n---\n\n## 0.2.0 Breaking Changes\n\n### 1. Default DispatchModeResolver Flipped to SmartDispatchModeResolver\n\nPre-0.2.0 the autoconfigured default was `BidirectionalStreamingDispatchModeResolver` — every request that may carry a body streamed both ways, ~24.1 µs per round-trip uniform. Since 0.2.0 the default is `SmartDispatchModeResolver`.\n\n| Request shape | Pre-0.2.0 mode | 0.2.0+ mode |\n|---------------|----------------|-------------|\n| Small/bodyless idempotent (GET/HEAD/PUT/DELETE/OPTIONS, ≤ 256 KiB CL or no CL) | `STREAMING` / `BIDIRECTIONAL_STREAMING` | `DIRECT` |\n| Small non-idempotent (POST/PATCH, ≤ 256 KiB CL) | `BIDIRECTIONAL_STREAMING` | `SYNC` |\n| Large or unknown-length body | `BIDIRECTIONAL_STREAMING` | `BIDIRECTIONAL_STREAMING` |\n\nOpt out (restore the pre-0.2.0 default):\n\n```yaml\nvespera:\n bridge:\n dispatch-mode: bidirectional-streaming\n```\n\nOr register a custom `DispatchModeResolver` bean — `@ConditionalOnMissingBean` ensures it wins over both the property and the autoconfigured default.\n\n### 2. DecodedResponse.body() Returns ByteBuffer\n\n`DecodedResponse.body()` now returns a read-only `java.nio.ByteBuffer` (zero-copy view over the wire bytes). The owned `byte[]` materialisation moved to `DecodedResponse.bodyBytes()`.\n\n```java\n// Before 0.2.0\nbyte[] body = resp.body();\n\n// After 0.2.0\nbyte[] body = resp.bodyBytes(); // owned copy\nByteBuffer view = resp.body(); // zero-copy view\n```\n\nCallers that previously consumed `body()` as `byte[]` must switch to `bodyBytes()`.\n\n---\n\n## Migrating from the JSON-Envelope Bridge (≤ 0.0.13)\n\nThe pre-0.0.14 bridge used `dispatch(String) → String` with base64-encoded binary bodies.\n\n| Before | After |\n|--------|-------|\n| `VesperaBridge.dispatch(json)` | `encodeRequest(...)` → `dispatchBytes(...)` → `decodeResponse(...)` |\n| `body_bytes_b64` field on the response JSON | raw body bytes after the wire header (no base64) |\n| ~33% size overhead on binary bodies | zero overhead |\n\nExisting users of `VesperaProxyController` need no code change — the controller was rewritten to the new wire path internally. Direct callers of `VesperaBridge.dispatch(String)` must update; the old method was removed in 0.0.14.\n","title":"Streaming & Multi-App","url":"/documentation/theme/theme-3"}] \ No newline at end of file diff --git a/apps/landing/src/app/documentation/[...name]/api.api-1.mdx b/apps/landing/src/app/documentation/[...name]/api.api-1.mdx index 7b4d68d7..eb18a44a 100644 --- a/apps/landing/src/app/documentation/[...name]/api.api-1.mdx +++ b/apps/landing/src/app/documentation/[...name]/api.api-1.mdx @@ -1 +1,133 @@ -empty \ No newline at end of file +# vespera! Macro + +The `vespera!()` macro is the entry point for every Vespera application. It scans your route folder at compile time, builds an `axum::Router` with all discovered handlers, and optionally writes an OpenAPI 3.1 spec file. + +## Full Parameter Reference + +```rust +let app = vespera!( + dir = "routes", // Route folder (default: "routes") + openapi = "openapi.json", // Output path (writes file at compile time) + title = "My API", // OpenAPI info.title + version = "1.0.0", // OpenAPI info.version (default: CARGO_PKG_VERSION) + docs_url = "/docs", // Swagger UI endpoint + redoc_url = "/redoc", // ReDoc endpoint + servers = [ // OpenAPI servers array + { url = "https://api.example.com", description = "Production" }, + { url = "http://localhost:3000", description = "Development" } + ], + merge = [crate1::App1, crate2::App2] // Merge child vespera apps +); +``` + +## Environment Variable Fallbacks + +Every parameter has a corresponding environment variable. The macro parameter takes priority over the env var, which takes priority over the built-in default. + +| Parameter | Environment Variable | Default | +|-----------|---------------------|---------| +| `dir` | `VESPERA_DIR` | `"routes"` | +| `openapi` | `VESPERA_OPENAPI` | none | +| `title` | `VESPERA_TITLE` | `"API"` | +| `version` | `VESPERA_VERSION` | `CARGO_PKG_VERSION` | +| `docs_url` | `VESPERA_DOCS_URL` | none | +| `redoc_url` | `VESPERA_REDOC_URL` | none | +| `servers` | `VESPERA_SERVER_URL` + `VESPERA_SERVER_DESCRIPTION` | none | + +## Common Patterns + +### Minimal — just a router + +```rust +let app = vespera!(); +``` + +### With Swagger UI + +```rust +let app = vespera!(docs_url = "/docs"); +``` + +### Write OpenAPI file + Swagger UI + +```rust +let app = vespera!( + openapi = "openapi.json", + docs_url = "/docs", + title = "My API", + version = "1.0.0" +); +``` + +### Multiple OpenAPI output files + +```rust +let app = vespera!( + openapi = ["openapi.json", "docs/api-spec.json"] +); +``` + +### Custom route folder + +```rust +// Scans src/api/ instead of src/routes/ +let app = vespera!(dir = "api"); +``` + +### With state and middleware + +```rust +let app = vespera!(docs_url = "/docs") + .with_state(AppState { db: pool }) + .layer(CorsLayer::permissive()) + .layer(TraceLayer::new_for_http()); +``` + +### Merging child apps + +```rust +let app = vespera!( + openapi = "openapi.json", + docs_url = "/docs", + merge = [billing::BillingApp, notifications::NotificationsApp] +) +.with_state(app_state); +``` + +## The `.serve()` Extension + +`vespera!()` returns an `axum::Router`. Vespera adds a `.serve(addr)` extension trait that replaces the usual `TcpListener::bind` + `axum::serve(...)` boilerplate: + +```rust +use vespera::{vespera, Serve}; + +#[tokio::main] +async fn main() -> std::io::Result<()> { + vespera!(docs_url = "/docs") + .serve("0.0.0.0:3000") + .await +} +``` + +`addr` accepts anything `tokio::net::ToSocketAddrs` takes — strings like `"0.0.0.0:3000"`, tuples like `([0, 0, 0, 0], 3000)`, or a `SocketAddr`. + +## export_app! Macro + +Export a Vespera app from a library crate so it can be merged into a parent app: + +```rust +// In the child crate's src/lib.rs +mod routes; + +// Scans "routes" folder by default +vespera::export_app!(MyApp); + +// Or with a custom directory +vespera::export_app!(MyApp, dir = "api"); +``` + +This generates a struct with two associated items: +- `MyApp::OPENAPI_SPEC: &'static str` — the OpenAPI JSON spec as a static string +- `MyApp::router() -> Router` — a function returning the Axum router + +The parent app merges it with `merge = [MyApp]` in `vespera!()`. diff --git a/apps/landing/src/app/documentation/[...name]/api.api-2.mdx b/apps/landing/src/app/documentation/[...name]/api.api-2.mdx index 7b4d68d7..26f48d47 100644 --- a/apps/landing/src/app/documentation/[...name]/api.api-2.mdx +++ b/apps/landing/src/app/documentation/[...name]/api.api-2.mdx @@ -1 +1,198 @@ -empty \ No newline at end of file +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, +} from '@/components/mdx/components/Table' + +# Route Attribute & Extractors + +`#[vespera::route]` marks a `pub async fn` as an HTTP handler. Vespera reads the function signature to extract path parameters, query parameters, request body, and response types for the OpenAPI spec. + +## Route Attribute Parameters + +```rust +#[vespera::route( + get, // HTTP method (default: get) + path = "/{id}", // Path suffix (appended to file-based prefix) + tags = ["users", "admin"], // OpenAPI tags + description = "Get user by ID" // OpenAPI operation description +)] +pub async fn get_user(Path(id): Path) -> Json { ... } +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| method | `get`, `post`, `put`, `patch`, `delete`, `head`, `options` | `get` | HTTP method | +| `path` | string | `""` | Path suffix appended to the file-based prefix | +| `tags` | string array | `[]` | OpenAPI tags for grouping in Swagger UI | +| `description` | string | `""` | OpenAPI operation description | + +## Extractor to OpenAPI Mapping + +Vespera reads your handler's extractor types and maps them to OpenAPI parameters and request bodies automatically: + + + + + Extractor + OpenAPI Location + Notes + + + + + `Path` + Path parameters + `T` can be a primitive or a struct + + + `Query` + Query parameters + Struct fields become individual query params + + + `Json` + Request body (`application/json`) + + + + `Form` + Request body (`application/x-www-form-urlencoded`) + + + + `TypedMultipart` + Request body (`multipart/form-data`) + Typed with schema + + + `Multipart` + Request body (`multipart/form-data`) + Untyped, generic object + + + `TypedHeader` + Header parameters + + + + `State` + Ignored + Internal — not part of the API + + + `Extension` + Ignored + Internal — not part of the API + + +
+ +## Examples + +### Path Parameters + +```rust +// Single path param +#[vespera::route(get, path = "/{id}")] +pub async fn get_user(Path(id): Path) -> Json { ... } + +// Multiple path params via struct +#[derive(Deserialize)] +pub struct PostParams { + pub user_id: u32, + pub post_id: u32, +} + +#[vespera::route(get, path = "/{user_id}/posts/{post_id}")] +pub async fn get_post(Path(params): Path) -> Json { ... } +``` + +### Query Parameters + +```rust +#[derive(Deserialize, Schema)] +pub struct ListUsersQuery { + pub page: Option, + pub limit: Option, + pub search: Option, +} + +#[vespera::route(get)] +pub async fn list_users(Query(q): Query) -> Json> { ... } +``` + +### JSON Body + +```rust +#[derive(Deserialize, Schema)] +pub struct CreateUserRequest { + pub name: String, + pub email: String, +} + +#[vespera::route(post)] +pub async fn create_user(Json(req): Json) -> Json { ... } +``` + +### Validated Body (with 422) + +```rust +use vespera::Validated; +use garde::Validate; + +#[derive(Deserialize, Schema, Validate)] +pub struct CreateUserRequest { + #[garde(length(min = 3, max = 32))] + pub username: String, + #[garde(email)] + pub email: String, +} + +#[vespera::route(post)] +pub async fn create_user( + Validated(Json(req)): Validated>, +) -> Json { ... } +``` + +### State (Ignored by OpenAPI) + +```rust +#[vespera::route(get)] +pub async fn list_users( + State(db): State, // ignored by OpenAPI + Query(q): Query, // included in OpenAPI +) -> Json> { ... } +``` + +### Error Responses + +```rust +#[derive(Serialize, Schema)] +pub struct ApiError { + pub message: String, +} + +#[vespera::route(get, path = "/{id}")] +pub async fn get_user( + Path(id): Path, +) -> Result, (StatusCode, Json)> { + if id == 0 { + return Err(( + StatusCode::NOT_FOUND, + Json(ApiError { message: "Not found".into() }), + )); + } + Ok(Json(User { id, name: "Alice".into() })) +} +``` + +## Handler Requirements + +- Must be `pub async fn` — private or non-async functions are ignored +- Must have `#[vespera::route]` attribute +- Can live anywhere in `src/routes/` (or your configured `dir`) +- The URL is: **file path prefix + `path` attribute value** diff --git a/apps/landing/src/app/documentation/[...name]/api.api-3.mdx b/apps/landing/src/app/documentation/[...name]/api.api-3.mdx index 7b4d68d7..e28c5bfd 100644 --- a/apps/landing/src/app/documentation/[...name]/api.api-3.mdx +++ b/apps/landing/src/app/documentation/[...name]/api.api-3.mdx @@ -1 +1,205 @@ -empty \ No newline at end of file +# schema_type!, schema!, and export_app! + +## schema_type! Macro + +Generate request/response types from existing structs. Perfect for creating API DTOs from database models without duplicating field definitions. + +### Basic Usage + +```rust +use vespera::schema_type; + +// Include only specific fields +schema_type!(CreateUserRequest from crate::models::user::Model, pick = ["name", "email"]); + +// Exclude specific fields +schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]); + +// Add new fields (disables auto From impl) +schema_type!(UpdateUserRequest from crate::models::user::Model, pick = ["name"], add = [("id": i32)]); +``` + +### Auto-Generated From Impl + +When `add` is NOT used, a `From` impl is generated automatically: + +```rust +schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]); + +// Use it directly: +let model: Model = db.find_user(id).await?; +Json(model.into()) // From impl handles the conversion +``` + +### Same-File Model Reference + +When the model is in the same file, use a simple name with the `name` parameter: + +```rust +// In src/models/user.rs +pub struct Model { + pub id: i32, + pub name: String, + pub email: String, +} + +vespera::schema_type!(Schema from Model, name = "UserSchema"); +``` + +### Cross-File References + +Reference structs from other files using full module paths: + +```rust +// In src/routes/users.rs +schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]); +``` + +### Partial Updates (PATCH) + +```rust +// All fields become Option +schema_type!(UserPatch from User, partial); + +// Only specific fields become Option +schema_type!(UserPatch from User, partial = ["name", "email"]); +``` + +### Omit Database Defaults + +`omit_default` automatically omits fields with `#[sea_orm(primary_key)]` or `#[sea_orm(default_value = "...")]` — perfect for create DTOs: + +```rust +#[derive(DeriveEntityModel)] +#[sea_orm(table_name = "posts")] +pub struct Model { + #[sea_orm(primary_key)] // omitted + pub id: i32, + pub title: String, + pub content: String, + #[sea_orm(default_value = "NOW()")] // omitted + pub created_at: DateTimeWithTimeZone, +} + +// Generated struct only has: title, content +schema_type!(CreatePostRequest from crate::models::post::Model, omit_default); + +// Combine with add +schema_type!(CreateItemRequest from Model, omit_default, add = [("tags": Vec)]); +``` + +### Multipart Mode + +Generate `Multipart` structs from existing types: + +```rust +#[derive(vespera::Multipart, vespera::Schema)] +pub struct CreateUploadRequest { + pub name: String, + #[form_data(limit = "10MiB")] + pub file: Option>, + pub description: Option, +} + +// Generates a Multipart struct (no serde derives), all fields Optional +schema_type!(PatchUploadRequest from CreateUploadRequest, multipart, partial, omit = ["file"]); +``` + +When `multipart` is enabled: +- Derives `Multipart` instead of `Serialize`/`Deserialize` +- Preserves `#[form_data(...)]` attributes from the source struct +- Skips SeaORM relation fields +- Does not generate a `From` impl + +### Same-File Relation Adapters + +When a route file defines local response DTOs for SeaORM relations, `schema_type!` generates compile adapters so existing handler code stays valid: + +```rust +#[derive(Serialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +pub struct UserInArticle { + pub id: Uuid, + pub name: String, + pub email: String, +} + +schema_type!( + ArticleResponse from crate::models::article::Model, + add = [("review_users": Vec)] +); + +// Handler code unchanged: +Ok(ArticleResponse { + user: user.into(), // adapter generated automatically + review_users, + .. +}) +``` + +The naming convention is `{RelationNamePascal}In{ResponseBase}` — `user` on `ArticleResponse` → `UserInArticle`. + +### All Parameters + +| Parameter | Description | +|-----------|-------------| +| `pick` | Include only specified fields | +| `omit` | Exclude specified fields | +| `rename` | Rename fields: `rename = [("old", "new")]` | +| `add` | Add new fields (disables auto `From` impl) | +| `clone` | Control Clone derive (default: `true`) | +| `partial` | Make fields optional: `partial` or `partial = ["field1"]` | +| `name` | Custom OpenAPI schema name (same-file references only) | +| `rename_all` | Serde rename strategy: `rename_all = "camelCase"` | +| `ignore` | Skip Schema derive (bare keyword) | +| `multipart` | Derive `Multipart` instead of serde (bare keyword) | +| `omit_default` | Auto-omit fields with DB defaults (bare keyword) | + +--- + +## schema! Macro + +Get a `Schema` value at runtime with optional field filtering. Useful for programmatic schema access without generating a new struct type. + +```rust +use vespera::{Schema, schema}; + +#[derive(Schema)] +pub struct User { + pub id: i32, + pub name: String, + pub password: String, +} + +// Full schema +let full: vespera::schema::Schema = schema!(User); + +// With fields omitted +let safe: vespera::schema::Schema = schema!(User, omit = ["password"]); + +// With only specified fields +let summary: vespera::schema::Schema = schema!(User, pick = ["id", "name"]); +``` + +> For creating request/response types with `From` impls, use `schema_type!` instead. + +--- + +## export_app! Macro + +Export a Vespera app from a library crate for merging into a parent app. See [vespera! Macro](/documentation/api/api-1) for the merge usage. + +```rust +// In the child crate's src/lib.rs +mod routes; + +// Scans "routes" folder by default +vespera::export_app!(MyApp); + +// Or with a custom directory +vespera::export_app!(MyApp, dir = "api"); +``` + +Generates: +- `MyApp::OPENAPI_SPEC: &'static str` — the OpenAPI JSON spec +- `MyApp::router() -> Router` — the Axum router diff --git a/apps/landing/src/app/documentation/[...name]/api.mdx b/apps/landing/src/app/documentation/[...name]/api.mdx index 7b4d68d7..b9b326c0 100644 --- a/apps/landing/src/app/documentation/[...name]/api.mdx +++ b/apps/landing/src/app/documentation/[...name]/api.mdx @@ -1 +1,23 @@ -empty \ No newline at end of file +# API Reference + +Complete reference for Vespera's macros and attributes. + +## vespera! Macro + +The entry point for every Vespera application. Scans your route folder at compile time, builds an `axum::Router`, and optionally writes an OpenAPI spec file. + +See [vespera! Macro](/documentation/api/api-1) for the full parameter reference. + +## Route Attribute & Extractors + +`#[vespera::route]` marks a `pub async fn` as an HTTP handler. Vespera reads the function signature to extract path parameters, query parameters, request body, and response types for the OpenAPI spec. + +See [Route Attribute & Extractors](/documentation/api/api-2) for all options and extractor mappings. + +## schema_type!, schema!, and export_app! + +- `schema_type!` — derive request/response DTOs from existing structs with `pick`, `omit`, `partial`, `add`, and SeaORM relation support +- `schema!` — get a `Schema` value at runtime with optional field filtering +- `export_app!` — export a Vespera app for merging into a parent app + +See [schema_type! & More](/documentation/api/api-3) for the full reference. diff --git a/apps/landing/src/app/documentation/[...name]/concept.concept-1.mdx b/apps/landing/src/app/documentation/[...name]/concept.concept-1.mdx index 56a0be64..b5fc97da 100644 --- a/apps/landing/src/app/documentation/[...name]/concept.concept-1.mdx +++ b/apps/landing/src/app/documentation/[...name]/concept.concept-1.mdx @@ -1,215 +1,100 @@ -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableRow, -} from '@/components/mdx/components/Table' - -export const metadata = { - title: 'What is Devup UI?', - alternates: { - canonical: '/docs/overview', - }, -} +# File-Based Routing + +Vespera maps your `src/routes/` folder structure directly to URL paths. The `vespera!()` macro scans the folder at compile time — no manual `Router::new().route(...)` calls needed. + +## Folder to URL Mapping + +``` +src/routes/ +├── mod.rs → / +├── users.rs → /users +├── posts.rs → /posts +└── admin/ + ├── mod.rs → /admin + └── stats.rs → /admin/stats +``` + +The final URL for a handler is: **file path prefix + `#[route]` path attribute**. + +```rust +// In src/routes/users.rs +#[vespera::route(get, path = "/{id}")] +pub async fn get_user(...) // → GET /users/{id} +``` + +## Handler Requirements + +Handlers must be `pub async fn`. Private or non-async functions are silently ignored by the scanner. + +```rust +// Ignored — private +async fn get_users() -> Json> { ... } -## What is Devup UI?eeeeeeeeeeee - -**Devup UI is not just another CSS-in-JS library — it's the future of CSS-in-JS itself.** - -Devup UI is a zero-runtime CSS-in-JS preprocessor powered by Rust and WebAssembly. It transforms all your styles at build time, completely eliminating runtime overhead while providing full CSS-in-JS syntax coverage. - -### The Problem with Traditional CSS-in-JS - -Traditional CSS-in-JS solutions force you to choose between: - -- **Developer Experience**: Intuitive APIs, co-located styles, dynamic theming -- **Performance**: No runtime overhead, fast page loads, optimal Core Web Vitals - -Libraries like styled-components and Emotion offer great DX but execute JavaScript at runtime to generate styles. Zero-runtime alternatives like Vanilla Extract sacrifice some flexibility for performance. - -### The Devup UI Solution - -Devup UI eliminates this trade-off entirely. Our Rust-powered preprocessor analyzes your code at build time and handles every CSS-in-JS pattern: - -- **Variables** — Dynamic values become CSS custom properties -- **Conditionals** — Ternary expressions are statically analyzed -- **Responsive Arrays** — Breakpoint-based styles are pre-generated -- **Pseudo Selectors** — `_hover`, `_focus`, `_active` work seamlessly -- **Themes** — Type-safe theme tokens with zero-cost switching - -### Key Advantages - - - - - Feature - Devup UI - styled-components - Emotion - Vanilla Extract - - - - - Zero Runtime - Yes - No - No - Yes - - - Dynamic Values - Yes - Yes - Yes - Limited - - - Full Syntax Coverage - Yes - Yes - Yes - No - - - Type-Safe Themes - Yes - Limited - Limited - Yes - - - Build Performance - Fastest - N/A - N/A - Fast - - -
- -### How It Works - -```tsx -// You write familiar CSS-in-JS syntax -const example = - -// Devup UI transforms it at build time -const generated =
- -// With optimized atomic CSS -// .a { background-color: red; } -// .b { padding: 16px; } /* 4 * 4 = 16px */ -// .c:hover { background-color: blue; } +// Ignored — not async +pub fn get_users() -> Json> { ... } + +// Discovered +pub async fn get_users() -> Json> { ... } ``` -> Numeric values are multiplied by 4. `p={4}` becomes `padding: 16px`. +## Route Attribute + +```rust +// GET /users (default method is GET) +#[vespera::route] +pub async fn list_users() -> Json> { ... } + +// POST /users +#[vespera::route(post)] +pub async fn create_user(Json(user): Json) -> Json { ... } + +// GET /users/{id} +#[vespera::route(get, path = "/{id}")] +pub async fn get_user(Path(id): Path) -> Json { ... } + +// PUT /users/{id} with tags and description +#[vespera::route(put, path = "/{id}", tags = ["users"], description = "Update user")] +pub async fn update_user(...) -> ... { ... } +``` -Class names use compact base-37 encoding (`a`, `b`, ... `z`, `_`, `aa`, `ab`, ...) for minimal CSS output. +### Attribute Parameters -### Familiar API +| Parameter | Type | Description | +|-----------|------|-------------| +| method | `get`, `post`, `put`, `patch`, `delete`, `head`, `options` | HTTP method (default: `get`) | +| `path` | string | Path suffix appended to the file-based prefix | +| `tags` | string array | OpenAPI tags for grouping in Swagger UI | +| `description` | string | OpenAPI operation description | -If you've used styled-components or Emotion, you'll feel right at home: +## Custom Route Folder -```tsx -import { styled } from '@devup-ui/react' +The default folder is `src/routes/`. Change it with the `dir` parameter or the `VESPERA_DIR` environment variable: -const Card = styled('div', { - bg: 'white', - p: 4, // 4 * 4 = 16px - borderRadius: '8px', - boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)', - _hover: { - boxShadow: '0 10px 15px rgba(0, 0, 0, 0.1)', - }, -}) +```rust +// Scans src/api/ instead of src/routes/ +let app = vespera!(dir = "api"); ``` -### Proven Performance - -Benchmarks on Next.js (GitHub Actions - ubuntu-latest): - - - - - Library - Version - Build Time - Build Size - - - - - tailwindcss - 4.1.13 - 19.31s - 59,521,539 bytes - - - styleX - 0.15.4 - 41.78s - 86,869,452 bytes - - - vanilla-extract - 1.17.4 - 19.50s - 61,494,033 bytes - - - kuma-ui - 1.5.9 - 20.93s - 69,924,179 bytes - - - panda-css - 1.3.1 - 20.64s - 64,573,260 bytes - - - chakra-ui - 3.27.0 - 28.81s - 222,435,802 bytes - - - mui - 7.3.2 - 20.86s - 97,964,458 bytes - - - **devup-ui (per-file css)** - **1.0.18** - **16.90s** - 59,540,459 bytes - - - **devup-ui (single css)** - **1.0.18** - **17.05s** - **59,520,196 bytes** - - - tailwindcss (turbopack) - 4.1.13 - 6.72s - 5,355,082 bytes - - - **devup-ui (single css + turbopack)** - **1.0.18** - 10.34s - **4,772,050 bytes** - - -
- -### Get Started - -Ready to experience the future of CSS-in-JS? Head to the [Installation](/docs/installation) guide to get started in minutes. +## Error Handling + +Return `Result` from handlers. Both `T` and `E` are included in the OpenAPI response schemas: + +```rust +#[derive(Serialize, Schema)] +pub struct ApiError { + pub message: String, +} + +#[vespera::route(get, path = "/{id}")] +pub async fn get_user( + Path(id): Path, +) -> Result, (StatusCode, Json)> { + if id == 0 { + return Err(( + StatusCode::NOT_FOUND, + Json(ApiError { message: "Not found".into() }), + )); + } + Ok(Json(User { id, name: "Alice".into() })) +} +``` diff --git a/apps/landing/src/app/documentation/[...name]/concept.concept-2.mdx b/apps/landing/src/app/documentation/[...name]/concept.concept-2.mdx index 7b4d68d7..9b912997 100644 --- a/apps/landing/src/app/documentation/[...name]/concept.concept-2.mdx +++ b/apps/landing/src/app/documentation/[...name]/concept.concept-2.mdx @@ -1 +1,216 @@ -empty \ No newline at end of file +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, +} from '@/components/mdx/components/Table' + +# Schema & OpenAPI Generation + +Vespera generates a complete OpenAPI 3.1 spec from your Rust types at compile time. Derive `Schema` on any type used in a handler's input or output and it appears in the spec automatically. + +## Deriving Schema + +```rust +#[derive(Serialize, Deserialize, vespera::Schema)] +pub struct User { + pub id: u32, + pub name: String, + pub email: String, + pub bio: Option, // optional — not in `required` array +} +``` + +Vespera respects all standard serde attributes: + +```rust +#[derive(Serialize, Deserialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +pub struct CreateUserRequest { + pub user_name: String, // → "userName" in OpenAPI + pub email: String, + + #[serde(rename = "fullName")] + pub name: String, // → "fullName" in OpenAPI + + #[serde(skip)] + pub internal_id: u64, // excluded from schema + + pub bio: Option, // optional field +} +``` + +## Type Mapping + + + + + Rust Type + OpenAPI Schema + + + + + `String`, `&str` + `string` + + + `i8`–`i128`, `u8`–`u128` + `integer` + + + `f32`, `f64` + `number` + + + `bool` + `boolean` + + + `Vec` + `array` with items + + + `Option` + T (parent marks field as optional) + + + `HashMap` + `object` with `additionalProperties` + + + `BTreeSet`, `HashSet` + `array` with `uniqueItems: true` + + + `Uuid` + `string` with `format: uuid` + + + `Decimal` + `string` with `format: decimal` + + + `NaiveDate` + `string` with `format: date` + + + `NaiveTime` + `string` with `format: time` + + + `DateTime`, `DateTimeWithTimeZone` + `string` with `format: date-time` + + + `FieldData` + `string` with `format: binary` + + + `()` + empty response (204 No Content) + + + Custom struct + `$ref` to `components/schemas` + + +
+ +## Generic Types + +All type parameters must also derive `Schema`: + +```rust +#[derive(Schema)] +struct Paginated { + items: Vec, + total: u32, + page: u32, +} +``` + +## SeaORM Integration + +`schema_type!` has first-class support for SeaORM models. Relation fields are converted automatically: + +```rust +#[derive(Clone, Debug, DeriveEntityModel)] +#[sea_orm(table_name = "memos")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub title: String, + pub user_id: i32, + pub user: BelongsTo, // → Option> + pub comments: HasMany, // → Vec + pub created_at: DateTimeWithTimeZone, // → chrono::DateTime +} + +vespera::schema_type!(Schema from Model, name = "MemoSchema"); +``` + + + + + SeaORM Type + Generated Schema Type + + + + + `HasOne` + `Box` or `Option>` + + + `BelongsTo` + `Option>` + + + `HasMany` + `Vec` + + + `DateTimeWithTimeZone` + `chrono::DateTime` + + +
+ +Circular references (e.g. User ↔ Memo) are detected automatically and handled by inlining fields to prevent infinite recursion. + +## Database Defaults in OpenAPI + +Fields with SeaORM database defaults get `default` values in the generated schema: + +| SeaORM Attribute | OpenAPI Default | +|-----------------|-----------------| +| `primary_key` (Uuid) | `"00000000-0000-0000-0000-000000000000"` | +| `primary_key` (i32/i64) | `0` | +| `default_value = "NOW()"` | `"1970-01-01T00:00:00+00:00"` | +| `default_value = "gen_random_uuid()"` | `"00000000-0000-0000-0000-000000000000"` | +| `default_value = "true"` | `true` | + +> `required` is determined solely by nullability (`Option`). Fields with defaults are still `required` unless they are `Option`. + +## Configuring the OpenAPI Output + +Pass parameters to `vespera!()` to control the spec: + +```rust +let app = vespera!( + openapi = "openapi.json", // write spec to this file at compile time + title = "My API", + version = "1.0.0", + docs_url = "/docs", // Swagger UI + redoc_url = "/redoc", // ReDoc + servers = [ + { url = "https://api.example.com", description = "Production" }, + { url = "http://localhost:3000", description = "Development" } + ] +); +``` + +See [vespera! Macro](/documentation/api/api-1) for the full parameter reference. diff --git a/apps/landing/src/app/documentation/[...name]/concept.concept-3.mdx b/apps/landing/src/app/documentation/[...name]/concept.concept-3.mdx index 7b4d68d7..4d7c597b 100644 --- a/apps/landing/src/app/documentation/[...name]/concept.concept-3.mdx +++ b/apps/landing/src/app/documentation/[...name]/concept.concept-3.mdx @@ -1 +1,132 @@ -empty \ No newline at end of file +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, +} from '@/components/mdx/components/Table' + +# `Validated` and 422 + +`Validated` is a Vespera extractor wrapper that runs [`garde`](https://crates.io/crates/garde) validation **before** your handler is called. Invalid requests are rejected with `422 Unprocessable Entity` and a canonical JSON error envelope — no per-handler error mapping, no boilerplate. + +## Basic Usage + +Add `garde` to your dependencies: + +```toml +[dependencies] +vespera = "0.1" +garde = { version = "0.20", features = ["derive"] } +``` + +Annotate your request type with `garde` constraints and derive `Validate`: + +```rust +use vespera::{Validated, Schema, axum::Json}; +use garde::Validate; + +#[derive(serde::Deserialize, Schema, Validate)] +pub struct CreateUser { + #[garde(length(min = 3, max = 32))] + pub username: String, + #[garde(email)] + pub email: String, + #[garde(range(min = 18, max = 120))] + pub age: u8, +} + +#[vespera::route(post, tags = ["users"])] +pub async fn create_user( + Validated(Json(req)): Validated>, +) -> Json<&'static str> { + // `req` has already passed garde validation — no manual checks needed. + Json("ok") +} +``` + +## 422 Response Envelope + +When validation fails, Vespera returns `HTTP 422 Unprocessable Entity` with this JSON body: + +```json +{ + "errors": [ + { "path": "username", "message": "length is lower than 3" }, + { "path": "email", "message": "not a valid email" } + ] +} +``` + +The envelope is identical regardless of which extractor failed — your API clients only need to handle one error shape. + +## Supported Extractors + +`Validated` works with every common Axum extractor: + + + + + Extractor + Validates + + + + + `Validated>` + JSON request body + + + `Validated>` + URL-encoded form body + + + `Validated>` + URL query parameters + + + `Validated>` + Path parameters + + +
+ +## JNI Hoisting + +Under JNI, the same `422` body is **hoisted** into the binary wire header as `"validation_errors": [...]`. Java decoders can read validation errors directly from the header without parsing the response body — no special-casing needed on the Java side. + +```json +{ + "v": 1, + "status": 422, + "headers": { "content-type": "application/json" }, + "validation_errors": [ + { "path": "username", "message": "length is lower than 3" } + ] +} +``` + +## Common garde Constraints + +```rust +#[derive(Deserialize, Schema, Validate)] +pub struct UpdateProfile { + #[garde(length(min = 1, max = 100))] + pub display_name: String, + + #[garde(url)] + pub website: Option, + + #[garde(length(min = 8))] + pub password: String, + + #[garde(range(min = 0.0, max = 5.0))] + pub rating: f64, + + #[garde(inner(length(min = 1)))] + pub tags: Vec, +} +``` + +See the [garde documentation](https://docs.rs/garde) for the full list of available constraints. diff --git a/apps/landing/src/app/documentation/[...name]/concept.mdx b/apps/landing/src/app/documentation/[...name]/concept.mdx index e69de29b..633b7952 100644 --- a/apps/landing/src/app/documentation/[...name]/concept.mdx +++ b/apps/landing/src/app/documentation/[...name]/concept.mdx @@ -0,0 +1,50 @@ +# Core Concepts + +Vespera is built on three ideas: file-based routing, compile-time schema extraction, and automatic request validation. + +## File-Based Routing + +Your folder structure becomes your URL structure. Drop a `pub async fn` with `#[vespera::route]` anywhere in `src/routes/` and Vespera discovers it at compile time — no manual router registration. + +``` +src/routes/ +├── mod.rs → / +├── users.rs → /users +├── posts.rs → /posts +└── admin/ + ├── mod.rs → /admin + └── stats.rs → /admin/stats +``` + +See [File-Based Routing](/documentation/concept/concept-1) for the full rules. + +## Schema & OpenAPI Generation + +Derive `Schema` on any Rust type and Vespera includes it in the generated OpenAPI 3.1 spec. Serde attributes (`rename_all`, `rename`, `skip`, `default`) are respected automatically. + +```rust +#[derive(Serialize, Deserialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +pub struct CreateUserRequest { + pub user_name: String, // → "userName" in OpenAPI + pub email: String, + pub bio: Option, // optional field +} +``` + +See [Schema & OpenAPI](/documentation/concept/concept-2) for type mapping and SeaORM integration. + +## `Validated` and 422 + +Wrap any extractor in `Validated` to run `garde` validation before the handler runs. Invalid requests are rejected with `422 Unprocessable Entity` and a canonical JSON error envelope — no per-handler error mapping needed. + +```rust +#[vespera::route(post)] +pub async fn create_user( + Validated(Json(req)): Validated>, +) -> Json<&'static str> { + Json("ok") +} +``` + +See [Validated & 422](/documentation/concept/concept-3) for the full contract. diff --git a/apps/landing/src/app/documentation/[...name]/features.mdx b/apps/landing/src/app/documentation/[...name]/features.mdx index 7b4d68d7..0a323484 100644 --- a/apps/landing/src/app/documentation/[...name]/features.mdx +++ b/apps/landing/src/app/documentation/[...name]/features.mdx @@ -1 +1,171 @@ -empty \ No newline at end of file +# Features + +Beyond routing and OpenAPI generation, Vespera ships several production-ready features that integrate with the same compile-time discovery system. + +## Cron Jobs + +Schedule background tasks with `#[vespera::cron]`. Jobs are auto-discovered like routes — no extra registration needed. + +### Enable the Feature + +```toml +[dependencies] +vespera = { version = "0.1", features = ["cron"] } +``` + +### Define Jobs + +Place `#[vespera::cron("...")]` on any `pub async fn` with zero parameters. The function can live anywhere in your project: + +```rust +// src/cron/cleanup.rs, src/tasks.rs, or even src/routes/users.rs — anywhere works +#[vespera::cron("1/10 * * * * *")] +pub async fn cleanup_sessions() { + println!("Running cleanup every 10 seconds"); +} + +#[vespera::cron("0 0 * * * *")] +pub async fn hourly_report() { + println!("Running hourly report"); +} +``` + +No extra config in `vespera!()` — jobs are discovered and started automatically: + +```rust +let app = vespera!(docs_url = "/docs"); +// Background scheduler starts when the app starts +``` + +### Cron Expression Format + +Uses 6-field cron expressions (`sec min hour day month weekday`): + +| Expression | Schedule | +|-----------|----------| +| `0 */5 * * * *` | Every 5 minutes | +| `0 0 * * * *` | Every hour | +| `0 0 0 * * *` | Daily at midnight | +| `1/10 * * * * *` | Every 10 seconds | +| `0 30 9 * * Mon-Fri` | Weekdays at 9:30 AM | + +### Requirements + +- Functions must be `pub async fn` +- Functions must take **no parameters** (no `State`, no extractors) +- The `cron` feature must be enabled in `Cargo.toml` + +--- + +## Multipart Form Data + +### Typed Multipart (Recommended) + +Use `TypedMultipart` for file uploads with a statically-known schema. Vespera generates `multipart/form-data` content type in OpenAPI and maps `FieldData` to `{ "type": "string", "format": "binary" }`: + +```rust +use vespera::multipart::{FieldData, TypedMultipart}; +use vespera::{Multipart, Schema}; +use tempfile::NamedTempFile; + +#[derive(Multipart, Schema)] +pub struct CreateUploadRequest { + pub name: String, + #[form_data(limit = "10MiB")] + pub file: Option>, +} + +#[vespera::route(post, tags = ["uploads"])] +pub async fn create_upload( + TypedMultipart(req): TypedMultipart, +) -> Json { ... } +``` + +### Raw Multipart (Untyped) + +For dynamic fields not known at compile time, use Axum's built-in `Multipart` extractor. Vespera generates a generic `{ "type": "object" }` schema: + +```rust +use vespera::axum::extract::Multipart; + +#[vespera::route(post, tags = ["uploads"])] +pub async fn upload(mut multipart: Multipart) -> Json { + while let Some(field) = multipart.next_field().await.unwrap() { + let name = field.name().unwrap_or("unknown").to_string(); + let data = field.bytes().await.unwrap(); + // Process each field dynamically... + } + Json(UploadResponse { success: true }) +} +``` + +--- + +## Merging Multiple Vespera Apps + +Combine routes and OpenAPI specs from multiple crates at compile time. Useful for splitting a large API into separate crates while presenting a single unified spec. + +### Export a Child App + +```rust +// In the child crate's src/lib.rs +mod routes; + +// Export for merging (scans "routes" folder by default) +vespera::export_app!(ThirdApp); + +// Or with a custom directory +vespera::export_app!(ThirdApp, dir = "api"); +``` + +This generates: +- `ThirdApp::OPENAPI_SPEC: &'static str` — the child's OpenAPI JSON +- `ThirdApp::router() -> Router` — the child's Axum router + +### Merge in the Parent App + +```rust +use vespera::vespera; + +let app = vespera!( + openapi = "openapi.json", + docs_url = "/docs", + merge = [third::ThirdApp, other::OtherApp] +) +.with_state(app_state); +``` + +Vespera automatically: +- Merges all child routes into the parent router +- Combines OpenAPI specs (paths, schemas, tags) into a single document +- Makes Swagger UI show all routes from all apps + +--- + +## Multi-App Routing (JNI) + +When embedding Vespera in a Java/Spring application via JNI, you can register multiple independent apps and route between them per request. + +```rust +pub fn create_app() -> axum::Router { vespera!(title = "Default") } +pub fn admin_app() -> axum::Router { vespera!(dir = "admin_routes", title = "Admin") } +pub fn public_app() -> axum::Router { vespera!(dir = "public_routes", title = "Public") } + +vespera::jni_apps! { + "_default" => create_app, + "admin" => admin_app, + "public" => public_app, +} +``` + +The Java side selects an app per request via the `X-Vespera-App` header (configurable): + +```bash +# Default app (no header) +curl http://localhost:8080/health + +# Admin app +curl -H "X-Vespera-App: admin" http://localhost:8080/dashboard +``` + +See [Streaming & Multi-App](/documentation/theme/theme-3) for the full multi-app routing reference. diff --git a/apps/landing/src/app/documentation/[...name]/installation.mdx b/apps/landing/src/app/documentation/[...name]/installation.mdx index 7b4d68d7..7582e873 100644 --- a/apps/landing/src/app/documentation/[...name]/installation.mdx +++ b/apps/landing/src/app/documentation/[...name]/installation.mdx @@ -1 +1,124 @@ -empty \ No newline at end of file +# Installation + +Get Vespera running in your Axum project in under five minutes. + +## 1. Add Dependencies + +```toml +[dependencies] +vespera = "0.1" +axum = "0.8" +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +``` + +> Vespera re-exports `axum` — use `vespera::axum` in your code instead of depending on `axum` directly. This keeps the version in sync automatically. + +## 2. Create Your First Route + +Create the routes folder and add a handler: + +``` +src/ +├── main.rs +└── routes/ + └── users.rs +``` + +**`src/routes/users.rs`**: + +```rust +use vespera::axum::{Json, extract::Path}; +use serde::{Deserialize, Serialize}; +use vespera::Schema; + +#[derive(Serialize, Deserialize, Schema)] +pub struct User { + pub id: u32, + pub name: String, +} + +/// Get user by ID +#[vespera::route(get, path = "/{id}", tags = ["users"])] +pub async fn get_user(Path(id): Path) -> Json { + Json(User { id, name: "Alice".into() }) +} + +/// Create a new user +#[vespera::route(post, tags = ["users"])] +pub async fn create_user(Json(user): Json) -> Json { + Json(user) +} +``` + +## 3. Set Up `main.rs` + +```rust +use vespera::{vespera, Serve}; + +#[tokio::main] +async fn main() -> std::io::Result<()> { + println!("Swagger UI: http://localhost:3000/docs"); + vespera!( + openapi = "openapi.json", + title = "My API", + docs_url = "/docs" + ) + .serve("0.0.0.0:3000") + .await +} +``` + +`.serve(addr)` is a Vespera extension trait on `axum::Router`. It replaces the usual `TcpListener::bind` + `axum::serve(...)` dance with a single chained call. `addr` accepts anything `tokio::net::ToSocketAddrs` takes — strings, tuples, or `SocketAddr`. + +## 4. Run + +```bash +cargo run +# Open http://localhost:3000/docs +``` + +Your Swagger UI is live. The `openapi.json` file is written to the project root at compile time. + +## Adding State and Middleware + +Chain standard Axum methods after `vespera!()`: + +```rust +let app = vespera!(docs_url = "/docs") + .with_state(AppState { db: pool }) + .layer(CorsLayer::permissive()) + .layer(TraceLayer::new_for_http()); +``` + +## JNI / Java Integration + +To embed Vespera inside a Java/Spring application, enable the `jni` feature: + +```toml +[dependencies] +vespera = { version = "0.1", features = ["jni"] } +``` + +Then add two lines to your Rust lib: + +```rust +pub fn create_app() -> vespera::axum::Router { + vespera!(title = "My API") +} + +vespera::jni_app!(create_app); +``` + +See the [JNI / Java Integration](/documentation/theme) section for the full setup guide. + +## Cron Jobs + +Enable the `cron` feature to schedule background tasks: + +```toml +[dependencies] +vespera = { version = "0.1", features = ["cron"] } +``` + +See [Features](/documentation/features) for usage details. diff --git a/apps/landing/src/app/documentation/[...name]/overview.mdx b/apps/landing/src/app/documentation/[...name]/overview.mdx index 2f40a4bc..0dba9e87 100644 --- a/apps/landing/src/app/documentation/[...name]/overview.mdx +++ b/apps/landing/src/app/documentation/[...name]/overview.mdx @@ -7,209 +7,176 @@ import { TableRow, } from '@/components/mdx/components/Table' -export const metadata = { - title: 'What is Devup UI?', - alternates: { - canonical: '/docs/overview', - }, -} +# What is Vespera? -## What is Devup UI? +**FastAPI-like developer experience for Rust.** Zero-config OpenAPI 3.1 generation for Axum. -**Devup UI is not just another CSS-in-JS library — it's the future of CSS-in-JS itself.** - -Devup UI is a zero-runtime CSS-in-JS preprocessor powered by Rust and WebAssembly. It transforms all your styles at build time, completely eliminating runtime overhead while providing full CSS-in-JS syntax coverage. - -### The Problem with Traditional CSS-in-JS - -Traditional CSS-in-JS solutions force you to choose between: - -- **Developer Experience**: Intuitive APIs, co-located styles, dynamic theming -- **Performance**: No runtime overhead, fast page loads, optimal Core Web Vitals - -Libraries like styled-components and Emotion offer great DX but execute JavaScript at runtime to generate styles. Zero-runtime alternatives like Vanilla Extract sacrifice some flexibility for performance. - -### The Devup UI Solution - -Devup UI eliminates this trade-off entirely. Our Rust-powered preprocessor analyzes your code at build time and handles every CSS-in-JS pattern: +```rust +// That's it. Swagger UI at /docs, OpenAPI at openapi.json +let app = vespera!(openapi = "openapi.json", docs_url = "/docs"); +``` -- **Variables** — Dynamic values become CSS custom properties -- **Conditionals** — Ternary expressions are statically analyzed -- **Responsive Arrays** — Breakpoint-based styles are pre-generated -- **Pseudo Selectors** — `_hover`, `_focus`, `_active` work seamlessly -- **Themes** — Type-safe theme tokens with zero-cost switching +Vespera scans your `src/routes/` folder at compile time, extracts every `#[vespera::route]` handler and `#[derive(Schema)]` type, and assembles a complete OpenAPI 3.1 spec — no annotations to maintain, no runtime reflection, no hand-written JSON. -### Key Advantages +## Why Vespera? Feature - Devup UI - styled-components - Emotion - Vanilla Extract + Vespera + Manual Approach - Zero Runtime - Yes - No - No - Yes + Route registration + Automatic (file-based) + Manual `Router::new().route(...)` + + + OpenAPI spec + Generated at compile time + Hand-written or runtime generation - Dynamic Values - Yes - Yes - Yes - Limited + Schema extraction + `#[derive(Schema)]` on Rust types + Manual JSON Schema - Full Syntax Coverage - Yes - Yes - Yes - No + Request validation + `Validated` extractor → auto `422` + Manual checks in every handler - Type-Safe Themes - Yes - Limited - Limited - Yes + Server startup + `.serve("0.0.0.0:3000")` one-liner + `TcpListener::bind` + `axum::serve` - Build Performance - Fastest - N/A - N/A - Fast + Swagger UI + Built-in + Separate setup + + + Type safety + Compile-time verified + Runtime errors
-### How It Works - -```tsx -// You write familiar CSS-in-JS syntax -const example = - -// Devup UI transforms it at build time -const generated =
- -// With optimized atomic CSS -// .a { background-color: red; } -// .b { padding: 16px; } /* 4 * 4 = 16px */ -// .c:hover { background-color: blue; } -``` - -> Numeric values are multiplied by 4. `p={4}` becomes `padding: 16px`. - -Class names use compact base-37 encoding (`a`, `b`, ... `z`, `_`, `aa`, `ab`, ...) for minimal CSS output. - -### Familiar API - -If you've used styled-components or Emotion, you'll feel right at home: - -```tsx -import { styled } from '@devup-ui/react' - -const Card = styled('div', { - bg: 'white', - p: 4, // 4 * 4 = 16px - borderRadius: '8px', - boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)', - _hover: { - boxShadow: '0 10px 15px rgba(0, 0, 0, 0.1)', - }, -}) -``` - -### Proven Performance - -Benchmarks on Next.js (GitHub Actions - ubuntu-latest): +## Headline Capabilities - Library - Version - Build Time - Build Size + Capability + How - tailwindcss - 4.1.13 - 19.31s - 59,521,539 bytes + `#[derive(Schema)]` → OpenAPI 3.1 + Rust types become JSON Schema at compile time, including serde renames, `Option`, `Vec`, SeaORM relations + + + `Validated` extractor + auto-`422` + Wraps `Json`/`Form`/`Query`/`Path` and runs `garde::Validate` before the handler — rejection is `422` with a canonical JSON envelope - styleX - 0.15.4 - 41.78s - 86,869,452 bytes + `schema_type! { ... }` + Derive request/response DTOs from existing structs (`pick` / `omit` / `partial` / `add` / `multipart` / `omit_default`) with first-class SeaORM relation support - vanilla-extract - 1.17.4 - 19.50s - 61,494,033 bytes + One-liner `.serve(addr)` + Extension trait on `axum::Router` — replaces `TcpListener::bind` + `axum::serve` boilerplate - kuma-ui - 1.5.9 - 20.93s - 69,924,179 bytes + JNI / Spring integration + Embed your Axum router inside a Java/Spring app in-process — no TCP, no base64, raw bytes end to end - panda-css - 1.3.1 - 20.64s - 64,573,260 bytes + Cron jobs + `#[vespera::cron("...")]` — auto-discovered like routes, runs via `tokio-cron-scheduler` + +
+ +## JNI Performance Numbers + +When embedding Vespera inside a Java/Spring application via JNI, the `SmartDispatchModeResolver` (default since vespera-bridge 0.2.0) picks the cheapest safe path per request. Measured on a `GET /health` round-trip through the real JNI boundary (AMD Ryzen 9 9950X, Java 21, Windows 11): + + + - chakra-ui - 3.27.0 - 28.81s - 222,435,802 bytes + Request shape + Mode + ns / round-trip + + - mui - 7.3.2 - 20.86s - 97,964,458 bytes + Small/bodyless + idempotent (GET/HEAD/PUT/DELETE/OPTIONS, ≤ 256 KiB) + `DIRECT` (pooled direct buffers) + ~2,200 ns - **devup-ui (per-file css)** - **1.0.18** - **16.90s** - 59,540,459 bytes + Small (≤ 256 KiB) + non-idempotent (POST/PATCH) + `SYNC` (heap-buffered) + ~3,200 ns - **devup-ui (single css)** - **1.0.18** - **17.05s** - **59,520,196 bytes** + Large or unknown-length body + `BIDIRECTIONAL_STREAMING` + ~24,100 ns + + +
+ +Binary streaming throughput (64 MiB payload, bidirectional): + + + + + Chunk size + Throughput + + + + + 16 KiB + ~10,408 MiB/s - tailwindcss (turbopack) - 4.1.13 - 6.72s - 5,355,082 bytes + 64 KiB + ~11,587 MiB/s - **devup-ui (single css + turbopack)** - **1.0.18** - 10.34s - **4,772,050 bytes** + 256 KiB + ~14,458 MiB/s
-### Get Started +The `direct_pooled` path completes a tiny `/health` round-trip in **2,349 ns/op** — **1.55× faster** than the pre-0.2.0 sync baseline (3,643 ns/op). + +## How It Works + +``` +src/routes/ +├── mod.rs → / +├── users.rs → /users +└── admin/ + └── stats.rs → /admin/stats +``` + +1. You place `pub async fn` handlers in `src/routes/` and annotate them with `#[vespera::route]`. +2. The `vespera!()` macro scans the folder at compile time, discovers every handler, and builds an `axum::Router`. +3. Types annotated with `#[derive(Schema)]` are extracted into OpenAPI component schemas automatically. +4. The generated `openapi.json` and Swagger UI are served at the URLs you configure. + +## Get Started -Ready to experience the future of CSS-in-JS? Head to the [Installation](/docs/installation) guide to get started in minutes. +Head to [Installation](/documentation/installation) to add Vespera to your project in under five minutes. diff --git a/apps/landing/src/app/documentation/[...name]/theme.mdx b/apps/landing/src/app/documentation/[...name]/theme.mdx index 7b4d68d7..8a0ab0d4 100644 --- a/apps/landing/src/app/documentation/[...name]/theme.mdx +++ b/apps/landing/src/app/documentation/[...name]/theme.mdx @@ -1 +1,47 @@ -empty \ No newline at end of file +# JNI / Java Integration + +Vespera can embed your Axum router directly inside a Java/Spring application — no TCP socket, no JSON envelope overhead, raw bytes from end to end. + +The `vespera-bridge` library (`kr.devfive:vespera-bridge`) provides a Spring Boot autoconfiguration that wires up a catch-all `VesperaProxyController`. Every HTTP request Spring receives is forwarded to Rust through a length-prefixed binary wire format, and the response comes back the same way. + +## Why In-Process? + +A traditional microservice setup adds a full HTTP round-trip between Java and Rust. In-process JNI dispatch eliminates that entirely: + +- No TCP connection overhead +- No JSON serialization of the envelope +- Binary bodies (multipart, PDFs, images) travel as raw bytes — no base64 +- Measured latency for small requests: **~2,200 ns** with the `DIRECT` dispatch mode + +## Quick Navigation + +- [jni_app! & VesperaBridge](/documentation/theme/theme-1) — Rust setup, Java setup, native library loading +- [Dispatch Modes & Wire Format](/documentation/theme/theme-2) — all seven dispatch methods, binary wire layout, `SmartDispatchModeResolver` defaults +- [Streaming & Multi-App](/documentation/theme/theme-3) — streaming tuning, multi-app routing, virtual thread notes, 0.2.0 breaking changes + +## Two-Line Integration + +**Rust side:** + +```rust +pub fn create_app() -> vespera::axum::Router { + vespera!(title = "My API") +} + +vespera::jni_app!(create_app); +``` + +**Java side:** + +```java +@SpringBootApplication +@ComponentScan(basePackages = {"com.example.app", "com.devfive.vespera.bridge"}) +public class MyApp { + public static void main(String[] args) { + VesperaBridge.init("my_rust_lib"); + SpringApplication.run(MyApp.class, args); + } +} +``` + +That's it. `VesperaProxyController` is autoconfigured and forwards every HTTP request to Rust. Zero controller code, zero `application.yml` config, zero extra imports beyond the Spring Boot starter. diff --git a/apps/landing/src/app/documentation/[...name]/theme.theme-1.mdx b/apps/landing/src/app/documentation/[...name]/theme.theme-1.mdx index 7b4d68d7..7a13296b 100644 --- a/apps/landing/src/app/documentation/[...name]/theme.theme-1.mdx +++ b/apps/landing/src/app/documentation/[...name]/theme.theme-1.mdx @@ -1 +1,191 @@ -empty \ No newline at end of file +# jni_app! & VesperaBridge + +## Rust Setup + +### 1. Enable the JNI Feature + +```toml +[dependencies] +vespera = { version = "0.1", features = ["jni"] } +``` + +The `jni` feature implies `inprocess` — both are enabled automatically. + +### 2. Export Your App + +In your cdylib crate's `src/lib.rs`: + +```rust +use vespera::{axum, vespera}; + +pub fn create_app() -> axum::Router { + vespera!(title = "My API", version = "1.0.0") +} + +// Single app — generates JNI_OnLoad and the dispatch symbol +vespera::jni_app!(create_app); +``` + +`jni_app!` generates all JNI boilerplate: `JNI_OnLoad`, the Tokio runtime, and the seven dispatch symbols. You write zero JNI code. + +### 3. Build as a cdylib + +```toml +[lib] +crate-type = ["cdylib"] +``` + +```bash +cargo build --release +# Produces: target/release/libmy_rust_lib.so (Linux) +# target/release/my_rust_lib.dll (Windows) +# target/release/libmy_rust_lib.dylib (macOS) +``` + +--- + +## Java Setup + +### Maven + +```xml + + kr.devfive + vespera-bridge + 0.2.0 + +``` + +### Gradle (Kotlin DSL) + +```kotlin +dependencies { + implementation("kr.devfive:vespera-bridge:0.2.0") +} +``` + +### Gradle Plugin (Recommended) + +The `kr.devfive.vespera-bridge` Gradle plugin replaces ~22 lines of native-library-bundling boilerplate with a 5-line block: + +```kotlin +plugins { + id("kr.devfive.vespera-bridge") version "0.1.1" +} + +vespera { + crateName.set("my_rust_lib") + cargoRoot.set(rootProject.layout.projectDirectory.dir("../..")) + bridgeVersion.set("0.2.0") +} +``` + +The plugin auto-wires `bundleNativeLib` (cdylib → `resources/native/-/`), the `processResources` dependency, and the `vespera-bridge` implementation dependency. + +### Spring Boot Application + +```java +@SpringBootApplication +@ComponentScan(basePackages = {"com.example.app", "com.devfive.vespera.bridge"}) +public class MyApp { + public static void main(String[] args) { + VesperaBridge.init("my_rust_lib"); // loads cdylib (bundled or system path) + SpringApplication.run(MyApp.class, args); + } +} +``` + +`VesperaProxyController` is autoconfigured via Spring Boot's `AutoConfiguration.imports`. It registers a `@RequestMapping("/**")` catch-all that forwards every HTTP request to Rust. The routes published in Vespera's generated `openapi.json` are reachable at the same URLs through Spring. + +--- + +## Native Library Loading + +`VesperaBridge.init("crateName")` tries two paths in order: + +1. **Bundled** — looks up `native/{os}-{arch}/{libname}` inside the running JAR's classpath. If found, the file is extracted to a temp file (auto-deleted on JVM exit) and loaded via `System.load`. +2. **Fallback** — `System.loadLibrary("crateName")` searches `java.library.path`. + +Supported platform triples: `linux-x86_64`, `linux-aarch64`, `macos-x86_64`, `macos-aarch64`, `windows-x86_64`. + +Place the cdylib at `src/main/resources/native/{os}-{arch}/` to bundle it inside the JAR for single-file deployment. + +--- + +## Zero-Config Defaults + +Out of the box the autoconfigure module wires up: + +| Concern | Default | Override | +|---------|---------|----------| +| App selection | Read `X-Vespera-App` request header; absent → default app | Property `vespera.bridge.app-header`, or custom `AppNameResolver` bean | +| Dispatch mode | `SmartDispatchModeResolver` since 0.2.0 — `DIRECT` for small/bodyless idempotent, `SYNC` for small non-idempotent, `BIDIRECTIONAL_STREAMING` for the rest | Property `vespera.bridge.dispatch-mode: bidirectional-streaming`, or custom `DispatchModeResolver` bean | +| URL pattern | `@RequestMapping("/**")` catch-all | Set `vespera.bridge.controller-enabled: false` and supply your own controller | + +--- + +## Customization + +### Tweak via application.yml + +```yaml +vespera: + bridge: + app-header: X-My-App # change the header that selects the app + controller-enabled: true # set false to disable the proxy controller +``` + +### Custom App-Selection Strategy + +```java +@Bean +public AppNameResolver myAppResolver() { + return request -> { + String uri = request.getRequestURI(); + if (uri.startsWith("/admin/")) return "admin"; + if (uri.startsWith("/public/")) return "public"; + return null; // default app + }; +} +``` + +Spring's `@ConditionalOnMissingBean` automatically disables `HeaderAppNameResolver` when you supply your own bean. + +### Custom Dispatch-Mode Policy + +```java +@Bean +public DispatchModeResolver myModeResolver() { + return request -> { + long contentLength = request.getContentLengthLong(); + if (contentLength >= 0 && contentLength < 4096 + && "application/json".equals(request.getContentType())) { + return DispatchMode.SYNC; + } + return DispatchMode.BIDIRECTIONAL_STREAMING; + }; +} +``` + +### BYO Controller + +```yaml +vespera: + bridge: + controller-enabled: false +``` + +```java +@RestController +public class MyController { + @PostMapping("/api/admin/{path}") + public ResponseEntity adminRoute(@PathVariable String path, @RequestBody byte[] body) { + byte[] wire = VesperaBridge.encodeRequest( + "admin", "POST", "/" + path, null, + Map.of("content-type", "application/json"), body); + byte[] resp = VesperaBridge.dispatchBytes(wire); + DecodedResponse d = VesperaBridge.decodeResponse(resp); + return ResponseEntity.status(d.status()).body(d.bodyBytes()); + } +} +``` diff --git a/apps/landing/src/app/documentation/[...name]/theme.theme-2.mdx b/apps/landing/src/app/documentation/[...name]/theme.theme-2.mdx index 7b4d68d7..bd5eb08e 100644 --- a/apps/landing/src/app/documentation/[...name]/theme.theme-2.mdx +++ b/apps/landing/src/app/documentation/[...name]/theme.theme-2.mdx @@ -1 +1,211 @@ -empty \ No newline at end of file +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, +} from '@/components/mdx/components/Table' + +# Dispatch Modes & Wire Format + +## Binary Wire Format + +Both request and response use the same length-prefixed layout: + +``` +bytes 0..4 : u32 BE = header_json byte length N +bytes 4..4+N : UTF-8 JSON + (request) { "v":1, "method", "path", + "query"?, "headers"? } + (response) { "v":1, "status", "headers", + "metadata", "validation_errors"? } +bytes 4+N.. : raw body bytes (UTF-8 text or binary — + no encoding applied) +``` + +Key properties: +- No base64 — multipart uploads, PDFs, and images travel as raw bytes +- `"v":1` is the protocol version; mismatched versions return a `400` wire response +- `"validation_errors"` is an optional array hoisted from `422` JSON bodies — Java decoders read validation errors from the header without parsing the body +- All failure paths (malformed wire, Rust panic, no app registered) return a valid length-prefixed response, so the decoder never has to special-case errors + +## Dispatch Modes + +`VesperaBridge` exposes seven native methods — all sharing the same wire format, the same registered router, and the same panic-safe `catch_unwind` discipline: + + + + + Method + Mode + Java return + Memory + + + + + `dispatchBytes(byte[])` + sync + `byte[]` (header + body) + full body in memory + + + `dispatchAsync(CompletableFuture, byte[])` + async + `void` (future completes) + full body in memory + + + `dispatchStreaming(byte[], OutputStream)` + sync, response-streaming + `byte[]` (header only) + chunk-bounded response + + + `dispatchFullStreaming(byte[], InputStream, OutputStream)` + sync, bidirectional streaming + `byte[]` (header only) + chunk-bounded both ways + + + `dispatchStreamingWithHeader(byte[], Consumer, OutputStream)` + sync, response-streaming + `void` (header via callback) + chunk-bounded response + + + `dispatchFullStreamingWithHeader(byte[], Consumer, InputStream, OutputStream)` + sync, bidirectional streaming + `void` (header via callback) + chunk-bounded both ways + + + `dispatchDirect(ByteBuffer, int, ByteBuffer)` + sync, direct buffers + `int` (response length / overflow code) + no Java heap arrays + + +
+ +### Choosing a Mode + +- Small JSON RPC, single request/response → `dispatchBytes` +- Hot small/bounded payloads where JNI copy overhead matters → `dispatchDirect` / `dispatchDirectPooled` +- Async I/O coordination (parallel Java requests, non-blocking) → `dispatchAsync` + `CompletableFuture` +- Large download / streaming response (video, PDF, SSE) → `dispatchStreaming` + `OutputStream` +- Large upload + large download (file transfer, video transcoding) → `dispatchFullStreaming` + `InputStream` + `OutputStream` +- The `*WithHeader` variants let Spring-style controllers commit status/headers before the first body byte is written + +## SmartDispatchModeResolver (Default since 0.2.0) + +The autoconfigured default since vespera-bridge 0.2.0 picks the cheapest safe path per request. Measured on a `GET /health` round-trip through the real JNI boundary: + +| Request shape | Mode | ns / round-trip | +|---------------|------|-----------------| +| Small/bodyless + idempotent (GET/HEAD/PUT/DELETE/OPTIONS, ≤ 256 KiB) | `DIRECT` | ~2,200 | +| Small (≤ 256 KiB Content-Length) + non-idempotent (POST/PATCH) | `SYNC` | ~3,200 | +| Large or unknown-length body | `BIDIRECTIONAL_STREAMING` | ~24,100 | + +Trade-offs: +- **DIRECT** writes the wire response straight into a pooled per-thread direct `ByteBuffer` (64 KiB → `vespera.direct.maxBufferBytes`, default 4 MiB). Responses larger than the pooled buffer trigger a single retry that **re-runs the Rust handler** — which is why DIRECT is gated on idempotent methods only. +- **SYNC** fully buffers the response on the JVM heap. The 256 KiB request-size gate keeps the response size reasonable for JSON-RPC-shaped traffic. +- **BIDIRECTIONAL_STREAMING** is unchanged for large/unknown-length bodies — multi-GB upload + multi-GB download runs chunk-bounded, ~32 KiB resident each side. + +Restore the pre-0.2.0 default (every request that may carry a body streams both ways, ~24 µs uniform): + +```yaml +vespera: + bridge: + dispatch-mode: bidirectional-streaming +``` + +## Direct Buffer Dispatch + +`dispatchDirect(ByteBuffer in, int inLen, ByteBuffer out)` eliminates the two JNI `GetByteArrayRegion`/`SetByteArrayRegion` copies that `dispatchBytes` pays. The response is streamed straight into the out buffer — no intermediate `Vec`. Measured at **1.4–3.4× per round-trip** versus `dispatchBytes` depending on payload size. + +Contract: +- Both buffers MUST be direct (`ByteBuffer.allocateDirect`); heap buffers are rejected with `IllegalArgumentException` +- The request is read from absolute offsets `in[0..inLen]` — the buffer's position/limit are ignored; `inLen` is authoritative +- Return `>= 0`: a complete wire response occupies `out[0..n]` +- Return `< 0`: `-(requiredSize)` — the response did not fit; **retrying re-runs the Rust handler**, so only retry idempotent requests +- `Integer.MIN_VALUE`: response exceeds 2 GiB + +`dispatchDirectPooled(byte[] wireRequest, boolean retryOnOverflow)` wraps the raw call with per-thread reusable direct buffers (64 KiB initial, doubling up to `vespera.direct.maxBufferBytes`, default 4 MiB). + +## Direct API (Without the Proxy Controller) + +```java +import com.devfive.vespera.bridge.VesperaBridge; +import com.devfive.vespera.bridge.VesperaBridge.DecodedResponse; + +// 1. Initialise once at startup +VesperaBridge.init("my_rust_lib"); + +// 2. Encode a request +byte[] wireRequest = VesperaBridge.encodeRequest( + "POST", + "/documents/validate", + /* query */ null, + Map.of("content-type", "application/json"), + "{\"title\":\"…\"}".getBytes(StandardCharsets.UTF_8)); + +// 3. Dispatch through Rust +byte[] wireResponse = VesperaBridge.dispatchBytes(wireRequest); + +// 4. Decode +DecodedResponse resp = VesperaBridge.decodeResponse(wireResponse); +System.out.println(resp.status()); // 200 +System.out.println(resp.headers()); // { "content-type": "application/json", … } +System.out.println(new String(resp.bodyBytes())); // copies the raw response body +``` + +> **0.2.0 breaking change:** `DecodedResponse.body()` now returns a read-only `java.nio.ByteBuffer` (zero-copy view over the wire bytes). The owned `byte[]` materialisation moved to `DecodedResponse.bodyBytes()`. Callers that previously used `body()` as `byte[]` must switch to `bodyBytes()`. + +## Async Dispatch + +```java +CompletableFuture future = VesperaBridge.dispatch(wireRequest); + +future.thenAccept(wireResponse -> { + DecodedResponse resp = VesperaBridge.decodeResponse(wireResponse); + System.out.println("Status: " + resp.status()); +}); +``` + +The future is **always** completed with a valid wire response, even on Rust panics or JNI conversion failures. You will never see a dangling future. + +## Streaming Dispatch + +```java +byte[] wireRequest = VesperaBridge.encodeRequest( + "GET", "/files/large.pdf", null, Map.of(), new byte[0]); + +try (ByteArrayOutputStream sink = new ByteArrayOutputStream()) { + byte[] headerOnly = VesperaBridge.dispatchStreaming(wireRequest, sink); + DecodedResponse meta = VesperaBridge.decodeResponse(headerOnly); + System.out.println("Status: " + meta.status()); + System.out.println("Body size: " + sink.size()); +} +``` + +## Bidirectional Streaming + +```java +try (InputStream upload = Files.newInputStream(Path.of("huge.mp4")); + OutputStream download = Files.newOutputStream(Path.of("transcoded.mp4"))) { + + byte[] wireHeader = VesperaBridge.encodeRequestHeader( + "POST", "/transcode", null, + Map.of("content-type", "video/mp4")); + + byte[] respHeader = VesperaBridge.dispatchFullStreaming( + wireHeader, upload, download); + + DecodedResponse meta = VesperaBridge.decodeResponse(respHeader); + System.out.println("Status: " + meta.status()); +} +``` + +A 1 GiB upload paired with a 1 GiB download runs in low-single-digit MiB resident memory on each side. Backpressure is enforced naturally — if Axum reads slowly, `InputStream.read()` blocks on the bounded channel. diff --git a/apps/landing/src/app/documentation/[...name]/theme.theme-3.mdx b/apps/landing/src/app/documentation/[...name]/theme.theme-3.mdx index e69de29b..2129c121 100644 --- a/apps/landing/src/app/documentation/[...name]/theme.theme-3.mdx +++ b/apps/landing/src/app/documentation/[...name]/theme.theme-3.mdx @@ -0,0 +1,177 @@ +# Streaming & Multi-App + +## Streaming Tuning + +Both streaming knobs are fixed for the process lifetime once the first dispatch runs. Configuration precedence (first hit wins): + +1. **Programmatic setter** — `VesperaBridge.configureStreaming(chunkBytes, channelCapacity)` (call before or after `init`) +2. **System properties** — `vespera.streaming.chunkBytes`, `vespera.streaming.channelCapacity` +3. **Environment variables** — `VESPERA_STREAMING_CHUNK_BYTES`, `VESPERA_STREAMING_CHANNEL_CAPACITY` +4. **Built-in defaults** — 256 KiB chunk size, 16 channel slots + +| Setting | System property | Env var | Default | Range | +|---------|----------------|---------|---------|-------| +| Chunk buffer size | `vespera.streaming.chunkBytes` | `VESPERA_STREAMING_CHUNK_BYTES` | 256 KiB | 4 KiB – 8 MiB | +| Request channel slots | `vespera.streaming.channelCapacity` | `VESPERA_STREAMING_CHANNEL_CAPACITY` | 16 | 1 – 1024 | +| Tokio worker threads | `vespera.runtime.workerThreads` | `VESPERA_RUNTIME_WORKERS` | logical CPUs | 1 – 1024 | + +### Java API + +Call before `VesperaBridge.init(...)` for guaranteed precedence: + +```java +VesperaBridge.configureStreaming( + 131072, // chunkBytes: 128 KiB (clamped to 4 KiB – 8 MiB) + 32 // channelCapacity: 32 slots (clamped to 1 – 1024) +); +VesperaBridge.init("my_rust_lib"); +``` + +When called before `init()`, values are stored as pending and applied immediately after the native library loads — before any dispatch can occur. This ensures the programmatic setter beats system properties and environment variables. + +Throws `IllegalArgumentException` if `chunkBytes` is outside `[4096, 8388608]` or `channelCapacity` is outside `[1, 1024]`. + +### System Properties + +```bash +java -Dvespera.streaming.chunkBytes=131072 \ + -Dvespera.streaming.channelCapacity=32 \ + -jar app.jar +``` + +### Environment Variables + +```bash +export VESPERA_STREAMING_CHUNK_BYTES=131072 +export VESPERA_STREAMING_CHANNEL_CAPACITY=32 +java -jar app.jar +``` + +### Tuning Tips + +- Larger chunks reduce the per-chunk JNI crossing cost (one `SetByteArrayRegion` + one `OutputStream.write` per chunk) at the price of per-stream memory. 256 KiB is a reasonable ceiling for throughput-oriented deployments. +- The Tokio worker-thread knob caps Rust's shared runtime — useful when the JVM's own pools (Tomcat request threads, virtual-thread carriers) compete with Tokio for the same cores, or when a container CPU limit is lower than the host's logical CPU count. + +--- + +## Multi-App Routing + +Multi-app routing is primarily a feature for external-dispatcher scenarios — JNI (Java host picks app per request via header), WebAssembly bridge, C FFI, or any in-process embedding where the host distinguishes between multiple independent Vespera API surfaces. + +### Rust Side + +```rust +pub fn create_app() -> axum::Router { vespera!(title = "Default") } +pub fn admin_app() -> axum::Router { vespera!(dir = "admin_routes", title = "Admin") } +pub fn public_app() -> axum::Router { vespera!(dir = "public_routes", title = "Public") } + +vespera::jni_apps! { + "_default" => create_app, + "admin" => admin_app, + "public" => public_app, +} +``` + +`jni_apps!` is the primary multi-app API. `jni_app!(create_app)` is syntactic sugar for a single default app. + +### Java Side + +The default `HeaderAppNameResolver` selects an app per request via the `X-Vespera-App` header: + +```bash +# Default app (no header) +curl http://localhost:8080/health + +# Admin app +curl -H "X-Vespera-App: admin" http://localhost:8080/dashboard + +# Public app +curl -H "X-Vespera-App: public" http://localhost:8080/info +``` + +Each app's URLs are independent — the same `/users` path can mean different things in `admin` vs `public` apps. Unknown app names return `404`; invalid app names (special characters, > 64 bytes) return `400`. + +### Custom App-Selection Strategy + +```java +@Bean +public AppNameResolver myAppResolver() { + // App name from the first path segment: + // /admin/dashboard → app "admin", path "/dashboard" + // /public/info → app "public", path "/info" + return request -> { + String uri = request.getRequestURI(); + if (uri.startsWith("/admin/")) return "admin"; + if (uri.startsWith("/public/")) return "public"; + return null; // default app + }; +} +``` + +--- + +## Virtual Thread (Project Loom) Limitation + +The pooled direct-buffer methods (`dispatchDirectPooled`) use `ThreadLocal` to maintain per-thread reusable buffers. In Java 21+, `ThreadLocal` binds to the **virtual thread** (not the carrier thread) — so in a virtual-thread-per-request server, each virtual thread allocates a fresh direct buffer and loses all pooling benefit. Direct memory accumulates until the virtual thread is garbage-collected, potentially causing memory pressure under high concurrency. + +**Recommendations for virtual-thread deployments:** + +- Set `vespera.bridge.dispatch-mode=bidirectional-streaming` to opt out of the smart default, so `DIRECT` is never chosen by the autoconfigured resolver. +- Or use `dispatchBytes`, `dispatchStreaming`, or `dispatchFullStreaming` directly instead of the pooled direct variants. +- Or run dispatch on a bounded platform-thread executor (e.g. a `ForkJoinPool` with a fixed parallelism cap). +- Or lower `vespera.direct.maxBufferBytes` to reduce per-thread allocation size. + +`DispatchMode.BIDIRECTIONAL_STREAMING` is safe for virtual threads and handles all payload sizes without pooling. + +--- + +## 0.2.0 Breaking Changes + +### 1. Default DispatchModeResolver Flipped to SmartDispatchModeResolver + +Pre-0.2.0 the autoconfigured default was `BidirectionalStreamingDispatchModeResolver` — every request that may carry a body streamed both ways, ~24.1 µs per round-trip uniform. Since 0.2.0 the default is `SmartDispatchModeResolver`. + +| Request shape | Pre-0.2.0 mode | 0.2.0+ mode | +|---------------|----------------|-------------| +| Small/bodyless idempotent (GET/HEAD/PUT/DELETE/OPTIONS, ≤ 256 KiB CL or no CL) | `STREAMING` / `BIDIRECTIONAL_STREAMING` | `DIRECT` | +| Small non-idempotent (POST/PATCH, ≤ 256 KiB CL) | `BIDIRECTIONAL_STREAMING` | `SYNC` | +| Large or unknown-length body | `BIDIRECTIONAL_STREAMING` | `BIDIRECTIONAL_STREAMING` | + +Opt out (restore the pre-0.2.0 default): + +```yaml +vespera: + bridge: + dispatch-mode: bidirectional-streaming +``` + +Or register a custom `DispatchModeResolver` bean — `@ConditionalOnMissingBean` ensures it wins over both the property and the autoconfigured default. + +### 2. DecodedResponse.body() Returns ByteBuffer + +`DecodedResponse.body()` now returns a read-only `java.nio.ByteBuffer` (zero-copy view over the wire bytes). The owned `byte[]` materialisation moved to `DecodedResponse.bodyBytes()`. + +```java +// Before 0.2.0 +byte[] body = resp.body(); + +// After 0.2.0 +byte[] body = resp.bodyBytes(); // owned copy +ByteBuffer view = resp.body(); // zero-copy view +``` + +Callers that previously consumed `body()` as `byte[]` must switch to `bodyBytes()`. + +--- + +## Migrating from the JSON-Envelope Bridge (≤ 0.0.13) + +The pre-0.0.14 bridge used `dispatch(String) → String` with base64-encoded binary bodies. + +| Before | After | +|--------|-------| +| `VesperaBridge.dispatch(json)` | `encodeRequest(...)` → `dispatchBytes(...)` → `decodeResponse(...)` | +| `body_bytes_b64` field on the response JSON | raw body bytes after the wire header (no base64) | +| ~33% size overhead on binary bodies | zero overhead | + +Existing users of `VesperaProxyController` need no code change — the controller was rewritten to the new wire path internally. Direct callers of `VesperaBridge.dispatch(String)` must update; the old method was removed in 0.0.14. diff --git a/apps/landing/src/app/page.tsx b/apps/landing/src/app/page.tsx index 6db7c656..010cf545 100644 --- a/apps/landing/src/app/page.tsx +++ b/apps/landing/src/app/page.tsx @@ -11,6 +11,7 @@ import { import { Button } from '@/components/button' import { GnbIcon } from '@/components/header/gnb-icon' import { HeaderSentinel } from '@/components/header/header-sentinel' +import { Performance } from '@/components/performance' export const metadata: Metadata = { alternates: { @@ -21,24 +22,24 @@ export const metadata: Metadata = { const EXAMPLES = [ { id: '1', - title: 'How to Use', + title: '1. Drop in a route', description: - 'Lorem ipsum dolor sit amet. Etiam sit amet feugiat turpis. Proin nec ante a sem vestibulum sodales non ut ex.', - imageUrl: '/images/hero.webp', + 'Write a pub async fn in src/routes/ with #[vespera::route]. The file path becomes the URL — no router wiring, no manual registration.', + imageUrl: '/images/rust-code.png', }, { id: '2', - title: 'How to Use', + title: '2. Serve with one macro', description: - 'Lorem ipsum dolor sit amet. Etiam sit amet feugiat turpis. Proin nec ante a sem vestibulum sodales non ut ex.', - imageUrl: '/images/join-us-bg.webp', + 'vespera!() discovers every route and cron job at compile time and generates your OpenAPI 3.1 spec. Chain .serve(addr) and Swagger UI is live at /docs.', + imageUrl: '/images/hero.webp', }, { id: '3', - title: 'How to Use', + title: '3. Embed in Spring — optional', description: - 'Lorem ipsum dolor sit amet. Etiam sit amet feugiat turpis. Proin nec ante a sem vestibulum sodales non ut ex.', - imageUrl: '/images/code.webp', + 'Add vespera::jni_app! and call VesperaBridge.init() from Java. The same router runs inside the JVM over a binary wire — microsecond round-trips, no TCP.', + imageUrl: '/images/join-us-bg.webp', }, ] @@ -63,18 +64,20 @@ export default function HomePage() { > - Lorem ipsum dolor sit amet,
- consectetur adipiscing elit. + The fastest way to ship
+ documented Rust APIs.
- Etiam sit amet feugiat turpis. Proin nec ante a sem vestibulum - sodales non ut ex.
- Morbi diam turpis, fringilla vitae enim et, egestas consequat - nibh.
- Etiam auctor cursus urna sit amet elementum. + Vespera turns plain Axum handlers into a typed, validated API + with OpenAPI 3.1 generated at compile time.
+ File-based routing, automatic Swagger UI, and a binary JNI + bridge that embeds your router
+ inside Spring Boot with microsecond round-trips.
- + + + @@ -88,20 +91,40 @@ export default function HomePage() { - Title + FastAPI-grade DX, Rust-grade performance - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam - venenatis, elit in hendrerit porta, augue ante scelerisque diam,{' '} -
- ac egestas lacus est nec urna. Cras commodo risus hendrerit, - suscipit nibh at, porttitor dui. + Vespera turns your Axum routes into a typed, validated, embeddable API + with one macro. File-based routing, compile-time OpenAPI 3.1, and a + JNI bridge that lets Spring host your Rust router with microsecond + round-trips — no TCP, no JSON envelope.
- {[0, 1, 2, 3].map((i) => ( + {[ + { + title: 'Zero-config OpenAPI 3.1', + description: + 'Drop handlers into src/routes/, derive Schema on your types, and Vespera generates the full OpenAPI 3.1 spec at compile time. No annotations, no runtime registration, no hand-written JSON.', + }, + { + title: 'Type-safe validation', + description: + 'Wrap any extractor in Validated and garde runs before your handler. Failures become a structured 422 response automatically — under JNI, errors are hoisted into the wire header so Java decoders never special-case error shapes.', + }, + { + title: 'Embed Rust in Spring', + description: + 'JNI in-process dispatch with a length-prefixed binary wire format. Multipart, PDFs, and images travel as raw bytes — no TCP socket, no JSON envelope, no base64 — the same Axum routes Spring users hit directly.', + }, + { + title: 'Microsecond dispatch', + description: + 'Sync round-trip in ~2.9 µs, direct ByteBuffer path in ~2.2 µs, streaming throughput up to 14.5 GB/s — measured end-to-end across the real JNI boundary, not just on the Rust side.', + }, + ].map(({ title, description }) => ( - Feature title + {title} - Lorem ipsum dolor sit amet. Etiam sit amet feugiat turpis. - Proin nec ante a sem vestibulum sodales non ut ex.{' '} + {description} @@ -127,6 +149,8 @@ export default function HomePage() {
+ + - Title + Zero to documented API in three steps - Lorem ipsum dolor sit amet, consectetur adipiscing elit. - Nullam venenatis ac egestas lacus est nec urna.{' '} + No boilerplate, no YAML, no hand-written specs — the macro + does the wiring, you write handlers.{' '} - + + + @@ -234,8 +260,8 @@ export default function HomePage() { Join our community - Join our Discord and help build the future of frontend with - CSS-in-JS!{' '} + Join our Discord to talk Rust APIs, JNI embedding, and what + Vespera should build next.{' '} diff --git a/apps/landing/src/components/performance/index.tsx b/apps/landing/src/components/performance/index.tsx new file mode 100644 index 00000000..09521b5c --- /dev/null +++ b/apps/landing/src/components/performance/index.tsx @@ -0,0 +1,121 @@ +import { css, Flex, Text, VStack } from '@devup-ui/react' +import Link from 'next/link' + +interface Stat { + value: string + unit: string + label: string + detail: string +} + +const STATS: Stat[] = [ + { + value: '2.2', + unit: 'µs', + label: 'Direct JNI dispatch', + detail: 'Per round-trip via pooled direct ByteBuffers', + }, + { + value: '2.9', + unit: 'µs', + label: 'Sync dispatch', + detail: 'Length-prefixed binary wire, no JSON envelope', + }, + { + value: '14.5', + unit: 'GB/s', + label: 'Streaming throughput', + detail: '256 KiB chunks, 3.3× faster than v0.x', + }, + { + value: '20', + unit: '%', + label: 'Faster sync dispatch', + detail: 'v0.2 zero-copy decode vs v0.1.1, same wire format', + }, +] + +export function Performance() { + return ( + + + + + Microsecond dispatch, gigabyte/s streaming + + + Vespera embeds your Axum router inside the JVM via JNI — zero TCP, zero + JSON envelope, raw bytes end-to-end. Numbers below are measured through the + real JNI boundary on AMD Ryzen 9 9950X, JDK 21. + + + + + {STATS.map((stat) => ( + + + + {stat.value} + + + {stat.unit} + + + + {stat.label} + + + {stat.detail} + + + ))} + + + + Latency measured on small GET /health round-trips through the real JNI + boundary; streaming throughput measured with a 64 MiB payload. Full + methodology and raw runs in the{' '} + + JNI benchmark report + + . + + + + ) +} diff --git a/apps/landing/src/constants/index.ts b/apps/landing/src/constants/index.ts index 6f768438..c5292052 100644 --- a/apps/landing/src/constants/index.ts +++ b/apps/landing/src/constants/index.ts @@ -7,36 +7,36 @@ export interface SideMenuItem { export const SIDE_MENU_ITEMS: Record = { documentation: [ { - label: '개요', + label: 'Overview', value: 'overview', }, - { label: '설치', value: 'installation' }, + { label: 'Installation', value: 'installation' }, { - label: '개념', + label: 'Core Concepts', value: 'concept', children: [ - { label: '개념 1', value: 'concept-1' }, - { label: '개념 2', value: 'concept-2' }, - { label: '개념 3', value: 'concept-3' }, + { label: 'File-Based Routing', value: 'concept-1' }, + { label: 'Schema & OpenAPI', value: 'concept-2' }, + { label: 'Validated & 422', value: 'concept-3' }, ], }, - { label: '특징', value: 'features' }, + { label: 'Features', value: 'features' }, { - label: 'API', + label: 'API Reference', value: 'api', children: [ - { label: 'API 1', value: 'api-1' }, - { label: 'API 2', value: 'api-2' }, - { label: 'API 3', value: 'api-3' }, + { label: 'vespera! Macro', value: 'api-1' }, + { label: 'Route & Extractors', value: 'api-2' }, + { label: 'schema_type! & More', value: 'api-3' }, ], }, { - label: '테마', + label: 'JNI / Java', value: 'theme', children: [ - { label: '테마 1', value: 'theme-1' }, - { label: '테마 2', value: 'theme-2' }, - { label: '테마 3', value: 'theme-3' }, + { label: 'jni_app! & VesperaBridge', value: 'theme-1' }, + { label: 'Dispatch Modes & Wire', value: 'theme-2' }, + { label: 'Streaming & Multi-App', value: 'theme-3' }, ], }, ], diff --git a/bun.lock b/bun.lock index 649ee60e..95d82811 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,7 @@ "bun-test-env-dom": "^1.0", "eslint-plugin-devup": "^2.0.19", "husky": "^9.1", - "oxlint": "^1.66.0", + "oxlint": "^1.69.0", }, }, "apps/landing": { @@ -19,12 +19,12 @@ "dependencies": { "@devup-api/fetch": "^0.1", "@devup-api/react-query": "^0.1", - "@devup-ui/components": "^0.1.46", + "@devup-ui/components": "^0.1.47", "@devup-ui/react": "^1", "@devup-ui/reset-css": "^1", "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", - "@next/mdx": "^16.2.6", + "@next/mdx": "^16.2.9", "clsx": "^2.1.1", "next": "^16", "react": "^19", @@ -32,15 +32,16 @@ "rehype-sanitize": "^6.0.0", "rehype-slug": "^6.0.0", "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", - "shiki": "^4.1.0", + "shiki": "^4.2.0", "unified": "^11.0.5", }, "devDependencies": { "@devup-api/next-plugin": "^0.1", "@devup-ui/next-plugin": "^1", - "@types/mdx": "^2.0.13", + "@types/mdx": "^2.0.14", "@types/node": "^25", "@types/react": "^19", "@types/react-syntax-highlighter": "^15.5.13", @@ -100,25 +101,25 @@ "@devup-api/webpack-plugin": ["@devup-api/webpack-plugin@0.1.13", "", { "dependencies": { "@devup-api/core": "^0.1.18", "@devup-api/generator": "^0.1.24", "@devup-api/utils": "^0.1.10" } }, "sha512-dQMqcMMdNUtzUHdaVYm29aIAU2S3+1EXLnWI3zsbVfF8X8isWqLlmwPS5aioY7iGDIYW4nL3C4gkIrhvT2pgpA=="], - "@devup-ui/bun-plugin": ["@devup-ui/bun-plugin@1.0.9", "", { "dependencies": { "@devup-ui/plugin-utils": "^1.0.7", "@devup-ui/wasm": "^1.0.74" } }, "sha512-Rj50un5MzTUiKdS7rlDh8DKrwhI4s4O+L1HtSr+Pw+/bo0mSMRRM8pr11umd7gUAXIlh0qgllwe3iagP9gZh6g=="], + "@devup-ui/bun-plugin": ["@devup-ui/bun-plugin@1.0.10", "", { "dependencies": { "@devup-ui/plugin-utils": "^1.0.8", "@devup-ui/wasm": "^1.0.75" } }, "sha512-GvCtLyCtS6FMXM6rg+s34N4XRFLfOdtzcMuLe61vsCloGhWn/XChWmQtnKJ0wDU7XfFUrgJCRW5BhsnV10hKOA=="], - "@devup-ui/components": ["@devup-ui/components@0.1.46", "", { "dependencies": { "@devup-ui/react": "^1.0.37", "clsx": "^2.1", "react": "^19.2.6" } }, "sha512-vZGMsACbB8YlBdrSLLq+3Lp2MoWw3vxoL6bYeepVqGiHLQaEZcyG1Iv1uymy7hAZYRlX7lgttJMhtVTyfyVdKA=="], + "@devup-ui/components": ["@devup-ui/components@0.1.47", "", { "dependencies": { "@devup-ui/react": "^1.0.37", "clsx": "^2.1", "react": "^19.2.7" } }, "sha512-B/V2fTbSUIFObF/Zz4gyGhDmuY3vmbej9678494VrmrAM/5JeaN/X+0quWGIpjwPy/rhvAW6nNLEf0XJPZQ7ew=="], - "@devup-ui/eslint-plugin": ["@devup-ui/eslint-plugin@1.0.15", "", { "dependencies": { "@typescript-eslint/utils": "^8.59", "typescript-eslint": "^8.59" } }, "sha512-vSOqvMTETHeF45X1JUxkkEkzoHTTgl8u/bJ3D9sybAoWNxvhcus5aDCOP1WHvJPQ1IG8/EMilxmrCyWNdkHJnA=="], + "@devup-ui/eslint-plugin": ["@devup-ui/eslint-plugin@1.0.16", "", { "dependencies": { "@typescript-eslint/utils": "^8.60", "typescript-eslint": "^8.60" } }, "sha512-gXhEVO9c4qGfR6HcCXsnRZHZlepDBZ1BnA0M2pB1/9asXSqWoJmt75xE0beXtt7wgrBHO/Z5gh+iX8Xu3e2ewQ=="], - "@devup-ui/next-plugin": ["@devup-ui/next-plugin@1.0.77", "", { "dependencies": { "@devup-ui/plugin-utils": "^1.0.7", "@devup-ui/wasm": "^1.0.74", "@devup-ui/webpack-plugin": "^1.0.60" }, "peerDependencies": { "next": "*" } }, "sha512-Ty2Jgv1AA2x0pttw3SF0qflB/Mfsx8+JtFm/j5VXwp/UjbMBkKSA19IR9sGRN9n+4DqpG5aOl7lJJmCNvmW6VQ=="], + "@devup-ui/next-plugin": ["@devup-ui/next-plugin@1.0.78", "", { "dependencies": { "@devup-ui/plugin-utils": "^1.0.8", "@devup-ui/wasm": "^1.0.75", "@devup-ui/webpack-plugin": "^1.0.61" }, "peerDependencies": { "next": "*" } }, "sha512-87PRiX5eP1J61F75PFmDMdEW4+aGFGLMPdgjcWdk2/y52LyMXJWQB3Vbtj1Z6fGRPFQOTIBUOWd9GVO7084YKA=="], - "@devup-ui/plugin-utils": ["@devup-ui/plugin-utils@1.0.7", "", {}, "sha512-KIVxYZCtkuLS29sDO/JRSWjO1fCQw/TnBD1J5u1KsLo134Q+8RogebWM/OeEJmMmGuiB9uiz06uzjG4h3BXLVg=="], + "@devup-ui/plugin-utils": ["@devup-ui/plugin-utils@1.0.8", "", {}, "sha512-Fyqmw4ZIkddNAT/GUE5+ur9tGelgAFAstE2j3Dfb+ypSGrhK9E2Ui9/0jBwI49GTBVbTG6fIDTnFgq0WpyJjRQ=="], "@devup-ui/react": ["@devup-ui/react@1.0.37", "", { "dependencies": { "csstype-extra": "latest", "react": "^19.2" } }, "sha512-zeHO2ke7X5vnM8w9vl4knDmXameG0X8OCb5E+qZPS2G4tsFJ98B3LKhioHTtnTs8YxxFaErRjUoeXylG4AiMpg=="], "@devup-ui/reset-css": ["@devup-ui/reset-css@1.0.24", "", { "dependencies": { "@devup-ui/react": "^1.0.36" } }, "sha512-yz2Pkbh5KyhqvHExajmXkwVUTBhh64XN4TyE6jgs7gogYE7ab8glPHtsBPEARTIPhK0MjLorDJswNVdMrbDw7w=="], - "@devup-ui/wasm": ["@devup-ui/wasm@1.0.74", "", {}, "sha512-pxlUTj2A/cZrf3KuFas1d2Xtfch998JPiYL7M8r227PZyG7CfcBBdniM7AcQCEx7mQrZ8NMM3DIldp2ZnD+1CA=="], + "@devup-ui/wasm": ["@devup-ui/wasm@1.0.75", "", {}, "sha512-MqANK1YxKqrYYxpFN8jb89nVzbLOGrlgh/oshfCwm9VaMFdx6qbWxAETlkHkXmkOp5UAhFOlgJbFLVm0MT1t2g=="], - "@devup-ui/webpack-plugin": ["@devup-ui/webpack-plugin@1.0.60", "", { "dependencies": { "@devup-ui/plugin-utils": "^1.0.7", "@devup-ui/wasm": "^1.0.74" } }, "sha512-e62sqaU7KNsmB76BmY+T8exWuBZ09i9L0li5wxqA7WS4bUDZKRV7eN4jIZ2/RBZ1tdWfmTcXqaEYoPv8pjUA8g=="], + "@devup-ui/webpack-plugin": ["@devup-ui/webpack-plugin@1.0.61", "", { "dependencies": { "@devup-ui/plugin-utils": "^1.0.8", "@devup-ui/wasm": "^1.0.75" } }, "sha512-YbGiXC0MxQ52cnO0Uw4EUJJoyHAf+f031hoHyn0IhetpN/wPEbOy/g4Uv+b4sZxWUlMrO2RL6TZsLfPIw7w+rQ=="], - "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], "@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=="], @@ -134,9 +135,9 @@ "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], - "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.9.0" } }, "sha512-lBW6/m5BIFl3pMuWPNN0lIOYw9LMCmPfix53ExS3FBi4E+NELEljQ3xH6aAV9IYiQRfn9YIIgzzMrD0vIcD7tw=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.10.3", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.10.3" } }, "sha512-rw10ox5gAdKT5UScrrhLRE8y9t2xzvRx2lUNwbXlPogJixYGciElqywuLlcmX+Rgcif0sF2wWUwqUEob1BKZTA=="], "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], @@ -216,25 +217,25 @@ "@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=="], - "@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="], + "@next/env": ["@next/env@16.2.9", "", {}, "sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg=="], - "@next/mdx": ["@next/mdx@16.2.6", "", { "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-0hdoSkzRbyud1dNRRDiyqD9FrxR2wwdiW+ffhYx+n+fXrFOJ7Nwpi8o7nUz2LiiM44BB9M0eIO1Evy3BBrS50A=="], + "@next/mdx": ["@next/mdx@16.2.9", "", { "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-SdweShKGCuN639JjyFSMQ8uldo+I+254+HucpjwdbFfaWHqUNN6dnQ1Of6laahnFyo48CcfDXEc2OBCS/Wfngw=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w=="], "@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=="], @@ -248,71 +249,71 @@ "@npmcli/promise-spawn": ["@npmcli/promise-spawn@7.0.2", "", { "dependencies": { "which": "^4.0.0" } }, "sha512-xhfYPXoV5Dy4UkY0D+v2KkwvnDfiA/8Mt3sWCGI/hM03NsYIH8ZaG6QzS9x7pje5vHZBZJ2v6VRFVTWACnqcmQ=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.66.0", "", { "os": "android", "cpu": "arm" }, "sha512-f7kq8N51T4phpzqfBpA2qaVTI/KrkCmNwaj3t/97I/WLTDI+UhlP5GL9eER+zVxBhtlx5rKXWByJU1/zDAvyaw=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.69.0", "", { "os": "android", "cpu": "arm" }, "sha512-DKQQbD5cZ/MYfDgDI7YGyGD9FSxABlsBsYFo5p26lloob543tP9+4N3guwdXIYJN+7HSZxLe8YJuwcOWw5qnHg=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.66.0", "", { "os": "android", "cpu": "arm64" }, "sha512-xu6QO71tdDS9mjmLZ3AqhtaVHBvdmsOKkYnReNNDgh+XiwnsipeQOIxbiYOOO0iAXycJ+GK0wdMSZP/2j/AmSg=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.69.0", "", { "os": "android", "cpu": "arm64" }, "sha512-lEhb+I5pr4inux+JFwfCa1HRq3Os7NirEFQ0H1I35SVEHPm6byX0Ah47xmRha3qi6LAkxUcxViL8o/9PivjzBg=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.66.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HZ24VimSOC7mxuEA99e0H2FS0C1yO3+iW13jPRAk+e2njsUs3QeAXsafCDyaIrV/MirdOVez+etQNQsJE43zNQ=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.69.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GY2YE8lOZW59BW1Ia1y+1gR0XyjrZRvVWHAr8LGeGhYHE0OQJ/7cRKXTkx1P+E9/6awEc3SX8a68SFTjh/E//A=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.66.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-awhj8ZvJrrRSnXj7V++rpZvTmnl99L6mi0B7gg7Cp7BN6cKpzuI481bHNLvXGA9GB1/oEgA3ponuyoAc6Md12A=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.69.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ax1oZnOjHX3LB7myQyHEaQkDwfLb6str3/nSP6O7EVUviQGNkEGzGV0EqcBJWK+Ufwx0l4xPgyYayurvhAdl2Q=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.66.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KQF0oVV21/FjIqkRuL8Q1vh8ECsE5+ocdH5tcqTQ4ZnYuDVoYibQUNfqBjQaUsP6UIIda5Y75Wpm5p4RgQWiWw=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.69.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kHWeHv4g2h8NY+mpCxzCtY4uerMJWTN/TSnNj1CPbakFpHEJ6cTya2wWV0pDSYWOJ2+0UiEbhn3AtXxHtsnKjg=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.66.0", "", { "os": "linux", "cpu": "arm" }, "sha512-9u1rgwZSEXWb30vbFZzQ78HVXBo0WCKNwJ3a2InRUTNMRng+PUDIoSFmA+m4HdUfBaIqftShq8J8qHc+eE/Vig=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.69.0", "", { "os": "linux", "cpu": "arm" }, "sha512-gq84vM1a1oEehXo27YCDzGVcxPsZDI1yswZwz2Da1/cbnWtrL16XZZnz0G/+gIU8edtHpfjxq5c+vWEHqJfWoQ=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.66.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Ynot2HR1bHxUaNWoC280MVTDfZuaWuP3XfSMRDhyuZrVjhzoaBCVFlw8h8qeZjWKVUBhPWFIxB7AQTlK8Z2WWg=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.69.0", "", { "os": "linux", "cpu": "arm" }, "sha512-kIqEa98JQ0VRyrcncxA417m2AzasqTlD+FyVT1AksjvjkqQcvm7pBWYvoW3/mpyOP2XYvi5nSCCTIe6De1yu5g=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.66.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-xCbgzciGgo+A4aQZEknsNrNiIwY7sU5SfRuMmRjPIvZAgdF34cIHiKvwOsS5XRLjlTVSFwitmq6YclTtHTfU+g=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.69.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j+xYiXozxGWx2cpjCrwwGR4awTxPFsRv3JZrv23RCogEPMc4R7UqjHW47p/RG0aRlbWiROCJ8coUfCwy0dvzHA=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.66.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-hmo+ZB/lHkR1HdDmnziNpzSLmulnUSu10VEqX2Yex7OwvoBAbjJQLvy4gIBRV3AAwWnCvAxKp5Nv1GE6LU1QMg=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.69.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-xEPpNppTfN1l/nM7gYSf9iocscu/as+p/7vxkLeLEKnYU+09Dm+5V6IhDYDh+Uz6FajEupWwCLt5SOG0y1PCKg=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.66.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2Invd4Uyy81mVooQC5FBtfxSNrvcX1OxbMlVQ6M2erRrNI2awFYF26YNW2yFxdVFZ4ffNOWKghtMjhnUPsXsVA=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.69.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Ug0+eU7HJBlek+SjklYH62IlOMirEJsdxpihH0kSqX0XdrDD4NdHpQc10fK1JC35yn6KrrcN+uYzlHD38XAf8Q=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.66.0", "", { "os": "linux", "cpu": "none" }, "sha512-s0iXPDQVdgayE3RGa/N2DZF7tjgg0TwEtD1sGoDxqPDGrIXgo45H0yHknT0f9A0yteASsweYZtDyTuVlM4aSag=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.69.0", "", { "os": "linux", "cpu": "none" }, "sha512-iEyI3GIg0l/s3G4qy2TlaaWKdzj4PJJStwtlocpDTC00PY9hZueotf6OKUj9+yfQh0lrpBW/pLMgTztbAHKJEg=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.66.0", "", { "os": "linux", "cpu": "none" }, "sha512-OekL4XFiu7RPK0JIZi8VeHgtIXPREf42t8Cy/rKEsC+P3gcqDgNAAGiyuUOpdbG4wwbfue1q4CHcCO7spSve6w=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.69.0", "", { "os": "linux", "cpu": "none" }, "sha512-NjHjpiI4WIKSMwuoJSZi5VToPeoYOS1FR52HLIDG6lidMdqquusgtODb4iLk0+lb1q3Z0nv2/aPRcC/olmpQGg=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.66.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Ga1D0kj1SFslm34ThA/BdkUlyAYEnTsXyRC4pF0C5agZSwtGdHYWMTQWemUfBGp4RCG4QWXgdO+HmmmKqOtlBg=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.69.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Ai/prDewoItkDXbp38gwGZi41DycZbUTZJ3UidwoHgQC0/DaqC2TGdtBTQLJ6hSD+SAxASzh8+/eSBPmxfOacA=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.66.0", "", { "os": "linux", "cpu": "x64" }, "sha512-p5jfP1wUZe/IC3qpQO84n9DRnf9g3lKRtLBlQq23ykyrDglHcVx7sWmVTlPuU6SBw8mNnPzyOn022G3XZHnlww=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.69.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Gt3KHgp46mRKz4sJeaASmKvD8ayXookRw07RMf+NowhEztGGDZ7VrXpoW96XuKJLjFukWizOFVNjmYb/u7caNQ=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.66.0", "", { "os": "linux", "cpu": "x64" }, "sha512-vUB/sYlYZorDL1ZD+o9mRv7zbsykrrFRtmgS6R8musZqLtrPRQn1gc1eGpuX+sfdccz42STl/AqldY6XRb2upQ=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.69.0", "", { "os": "linux", "cpu": "x64" }, "sha512-7tQhJ2+p/oHv1zcfnjYI7YVzC/7iBaVOfIvFYtxdJ5F45mWgEdrCyXZXZGfiLey5t/5JhOhsaMnnv1kAzckd7g=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.66.0", "", { "os": "none", "cpu": "arm64" }, "sha512-yde+6p/F59xRkGR9H1HfngWRif1QRJjynZK349l+UI0H6w9hL3G8/AVaTHFyTtLVQ56qtNbX2/5Dc77n1ovnOg=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.69.0", "", { "os": "none", "cpu": "arm64" }, "sha512-vmWz6TKp/3hfA4lksR0zHBv/6xuX1jhym6eqOjdH2DXsDDHZWcp2f0KG0VCAnlVbIrjk29G4wAWMXb/Hn1YobA=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.66.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-O9GLucgoTdmOrbBX+EjzNe7o/Ze5TFOvXcib6bzUOtBOmj6cV+zw18NgB+cGKAkDw1Pdqs8vGkfHbbsLuDtXWg=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.69.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-9RExaLgmaw6IoIkU9cTpT71mLfI0xZ86iZH8x518LVsOkjquJMYqb9P7KpC8lgd1t0Dxs41p2pxynq4XR3Ttzw=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.66.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-m3Pjwc2MfTcom4E4gOv7DyuGyt7OfGNCbmqDHd+N7EzXmP+ppHuudm2NjcA3AjV5TSeGxaguVF4SbTKHe1USYA=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.69.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-1907kRPF8/PrcIw1E7LMs9JbVrpgnt/MvFdss3an8oDkYNAACXzTntV3t3869ZZhMZxb2AzRGbz1pA/jdFatXA=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.66.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/DbBvw8UFBhja6PqudUjV4UtfsJr0Oa7jUjWVKB0g86lj/VwnPrkngn0sFql3c9RDA0O16dh7ozsXb6GjNAzBQ=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.69.0", "", { "os": "win32", "cpu": "x64" }, "sha512-w8SOXv3mT9Fi6jY8OXdXCfnvX/3KNLXGNr4HEz2TA7S4Mv/PYAOmpB8y/ge40mxvBMgGNaSaaDwZpAsQn7HtWA=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], - "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], + "@pkgr/core": ["@pkgr/core@0.3.6", "", {}, "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA=="], - "@shikijs/core": ["@shikijs/core@4.1.0", "", { "dependencies": { "@shikijs/primitive": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ=="], + "@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="], - "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ=="], + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="], - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g=="], - "@shikijs/langs": ["@shikijs/langs@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg=="], + "@shikijs/langs": ["@shikijs/langs@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ=="], - "@shikijs/primitive": ["@shikijs/primitive@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw=="], + "@shikijs/primitive": ["@shikijs/primitive@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA=="], - "@shikijs/themes": ["@shikijs/themes@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw=="], + "@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="], - "@shikijs/types": ["@shikijs/types@4.1.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA=="], + "@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], "@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.14", "", { "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-NbpiBCmeHTRuVHeV5+U+1bzmxyTW5Dzp2sCeE6Hx+ZJTJWFK9dsm8VZmRc7LQP9/ZORsF620PvgUk67AwiBo4A=="], + "@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.101.0", "", { "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-wsfg821y4yw21J7nKI2oM5yyGSz3vASXqgWbmWCXZpnyY9ObLrBCcXivwZKj4YHF2fUWiqoOIRX2pbE79cf6gQ=="], - "@tanstack/query-core": ["@tanstack/query-core@5.100.14", "", {}, "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew=="], + "@tanstack/query-core": ["@tanstack/query-core@5.101.0", "", {}, "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow=="], - "@tanstack/react-query": ["@tanstack/react-query@5.100.14", "", { "dependencies": { "@tanstack/query-core": "5.100.14" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw=="], + "@tanstack/react-query": ["@tanstack/react-query@5.101.0", "", { "dependencies": { "@tanstack/query-core": "5.101.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg=="], "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], @@ -344,13 +345,13 @@ "@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/mdx": ["@types/mdx@2.0.14", "", {}, "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + "@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], - "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], "@types/react-syntax-highlighter": ["@types/react-syntax-highlighter@15.5.13", "", { "dependencies": { "@types/react": "*" } }, "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA=="], @@ -362,31 +363,31 @@ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.4", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/type-utils": "8.59.4", "@typescript-eslint/utils": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.4", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.61.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/type-utils": "8.61.0", "@typescript-eslint/utils": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.61.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.4", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.61.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.4", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.4", "@typescript-eslint/types": "^8.59.4", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.61.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.61.0", "@typescript-eslint/types": "^8.61.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4" } }, "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0" } }, "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.4", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.61.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/utils": "8.59.4", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/utils": "8.61.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.59.4", "", {}, "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.61.0", "", {}, "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.4", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.4", "@typescript-eslint/tsconfig-utils": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.61.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.61.0", "@typescript-eslint/tsconfig-utils": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.61.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "eslint-visitor-keys": "^5.0.0" } }, "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], "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": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -424,7 +425,7 @@ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.32", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.37", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig=="], "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], @@ -432,6 +433,8 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "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.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], @@ -442,7 +445,7 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - "caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="], + "caniuse-lite": ["caniuse-lite@1.0.30001799", "", {}, "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -512,7 +515,7 @@ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], - "electron-to-chromium": ["electron-to-chromium@1.5.361", "", {}, "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.372", "", {}, "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], @@ -528,7 +531,7 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - "es-iterator-helpers": ["es-iterator-helpers@1.3.2", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "math-intrinsics": "^1.1.0" } }, "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw=="], + "es-iterator-helpers": ["es-iterator-helpers@1.3.3", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "math-intrinsics": "^1.1.0" } }, "sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g=="], "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], @@ -546,17 +549,17 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@10.4.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@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-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ=="], + "eslint": ["eslint@10.5.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@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-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ=="], "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], - "eslint-mdx": ["eslint-mdx@3.7.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "espree": "^9.6.1 || ^10.4.0", "estree-util-visit": "^2.0.0", "remark-mdx": "^3.1.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "synckit": "^0.11.8", "unified": "^11.0.5", "unified-engine": "^11.2.2", "unist-util-visit": "^5.0.0", "vfile": "^6.0.3" }, "peerDependencies": { "eslint": ">=8.0.0", "remark-lint-file-extension": "*" }, "optionalPeers": ["remark-lint-file-extension"] }, "sha512-QpPdJ6EeFthHuIrfgnWneZgwwFNOLFj/nf2jg/tOTBoiUnqNTxUUpTGAn0ZFHYEh5htVVoe5kjvD02oKtxZGeA=="], + "eslint-mdx": ["eslint-mdx@3.8.1", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "espree": "^9.6.1 || ^10.4.0 || ^11.2.0", "estree-util-visit": "^2.0.0", "remark-mdx": "^3.1.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "synckit": "^0.11.8", "unified": "^11.0.5", "unified-engine": "^11.2.2", "unist-util-visit": "^5.0.0", "vfile": "^6.0.3" }, "peerDependencies": { "eslint": ">=8.0.0", "remark-lint-file-extension": "*" }, "optionalPeers": ["remark-lint-file-extension"] }, "sha512-hnsqWwMOHqUANwxWEGt8XbwABPEr5sTOolAzqyUDFdlERpqjFE/icylb+mJl60VICL+kLbbvXWbnFLWZdTqJ2g=="], "eslint-plugin-devup": ["eslint-plugin-devup@2.0.19", "", { "dependencies": { "@devup-ui/eslint-plugin": ">=1.0.14", "@eslint/js": ">=10.0", "@tanstack/eslint-plugin-query": ">=5.100.6", "eslint": ">=10.2", "eslint-config-prettier": ">=10", "eslint-plugin-mdx": ">=3.7.0", "eslint-plugin-prettier": ">=5.5.5", "eslint-plugin-react": ">=7.37.5", "eslint-plugin-react-hooks": ">=7", "eslint-plugin-simple-import-sort": ">=13.0.0", "eslint-plugin-unused-imports": ">=4.4.1", "prettier": ">=3", "typescript-eslint": ">=8.59" } }, "sha512-E1CwZp4kjy/py/xztR1cXOF/FuzEuGc2GaYEK3cCaAtVna0rTT9TwxPKcTpGQIJvjlZHNxEl5BoeJdARC8GGPQ=="], - "eslint-plugin-mdx": ["eslint-plugin-mdx@3.7.0", "", { "dependencies": { "eslint-mdx": "^3.7.0", "mdast-util-from-markdown": "^2.0.2", "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0", "remark-mdx": "^3.1.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "synckit": "^0.11.8", "unified": "^11.0.5", "vfile": "^6.0.3" }, "peerDependencies": { "eslint": ">=8.0.0" } }, "sha512-JXaaQPnKqyti/QSOSQDThLV1EemHm/Fe2l/nMKH0vmhvmABtN/yV/9+GtKgh8UTZwrwuTfQq1HW5eR8HXneNLA=="], + "eslint-plugin-mdx": ["eslint-plugin-mdx@3.8.1", "", { "dependencies": { "eslint-mdx": "^3.8.1", "mdast-util-from-markdown": "^2.0.2", "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0", "remark-mdx": "^3.1.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "synckit": "^0.11.8", "unified": "^11.0.5", "vfile": "^6.0.3" }, "peerDependencies": { "eslint": ">=8.0.0" } }, "sha512-4OLgotfBxUDc1f6ihXSagT/1+JCCUABA/2r6Kzl6gqFftg4dCV0wBfdwFo6X6UO/FzTHr3g6mVt+6prRXffc/Q=="], - "eslint-plugin-prettier": ["eslint-plugin-prettier@5.5.5", "", { "dependencies": { "prettier-linter-helpers": "^1.0.1", "synckit": "^0.11.12" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "optionalPeers": ["@types/eslint", "eslint-config-prettier"] }, "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw=="], + "eslint-plugin-prettier": ["eslint-plugin-prettier@5.5.6", "", { "dependencies": { "prettier-linter-helpers": "^1.0.1", "synckit": "^0.11.13" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "optionalPeers": ["@types/eslint", "eslint-config-prettier"] }, "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ=="], "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], @@ -620,7 +623,7 @@ "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=="], + "function.prototype.name": ["function.prototype.name@1.2.0", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2", "hasown": "^2.0.4", "is-callable": "^1.2.7", "is-document.all": "^1.0.0" } }, "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew=="], "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], @@ -644,7 +647,7 @@ "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], - "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=="], + "happy-dom": ["happy-dom@20.10.3", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-Hjdiy8RziuCcn5z04QI/rlsNuQoG8P0xxjgvsSMpi89cvIXIOcucQtiHS1yHSShxoBcSCeYqAskINmTiy/mlfw=="], "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], @@ -656,7 +659,7 @@ "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], - "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], @@ -730,6 +733,8 @@ "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + "is-document.all": ["is-document.all@1.0.0", "", { "dependencies": { "call-bound": "^1.0.4" } }, "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g=="], + "is-empty": ["is-empty@1.2.0", "", {}, "sha512-F2FnH/otLNJv0J6wc73A5Xo7oHLNnqplYqZhUu01tD54DIPvxIRSTSLkrUB/M0nHO4vo1O9PDfN4KoTxCzLh/w=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], @@ -816,10 +821,26 @@ "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + "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=="], + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + "mdast-util-mdx": ["mdast-util-mdx@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w=="], "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], @@ -840,6 +861,20 @@ "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=="], + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + "micromark-extension-mdx-expression": ["micromark-extension-mdx-expression@3.0.1", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q=="], "micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.2", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ=="], @@ -904,11 +939,11 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - "next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@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.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "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-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="], + "next": ["next@16.2.9", "", { "dependencies": { "@next/env": "16.2.9", "@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.9", "@next/swc-darwin-x64": "16.2.9", "@next/swc-linux-arm64-gnu": "16.2.9", "@next/swc-linux-arm64-musl": "16.2.9", "@next/swc-linux-x64-gnu": "16.2.9", "@next/swc-linux-x64-musl": "16.2.9", "@next/swc-win32-arm64-msvc": "16.2.9", "@next/swc-win32-x64-msvc": "16.2.9", "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-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww=="], "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.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="], + "node-releases": ["node-releases@2.0.47", "", {}, "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og=="], "nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="], @@ -944,7 +979,7 @@ "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.66.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.66.0", "@oxlint/binding-android-arm64": "1.66.0", "@oxlint/binding-darwin-arm64": "1.66.0", "@oxlint/binding-darwin-x64": "1.66.0", "@oxlint/binding-freebsd-x64": "1.66.0", "@oxlint/binding-linux-arm-gnueabihf": "1.66.0", "@oxlint/binding-linux-arm-musleabihf": "1.66.0", "@oxlint/binding-linux-arm64-gnu": "1.66.0", "@oxlint/binding-linux-arm64-musl": "1.66.0", "@oxlint/binding-linux-ppc64-gnu": "1.66.0", "@oxlint/binding-linux-riscv64-gnu": "1.66.0", "@oxlint/binding-linux-riscv64-musl": "1.66.0", "@oxlint/binding-linux-s390x-gnu": "1.66.0", "@oxlint/binding-linux-x64-gnu": "1.66.0", "@oxlint/binding-linux-x64-musl": "1.66.0", "@oxlint/binding-openharmony-arm64": "1.66.0", "@oxlint/binding-win32-arm64-msvc": "1.66.0", "@oxlint/binding-win32-ia32-msvc": "1.66.0", "@oxlint/binding-win32-x64-msvc": "1.66.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-N4LLxYLd94KEBqXDMDM5f+2PUpItTjDLreXe2Gn5KhjhCK4Qp2YUXaBi8Yu325ryOgKwt22m45fpD7nPOn69Yw=="], + "oxlint": ["oxlint@1.69.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.69.0", "@oxlint/binding-android-arm64": "1.69.0", "@oxlint/binding-darwin-arm64": "1.69.0", "@oxlint/binding-darwin-x64": "1.69.0", "@oxlint/binding-freebsd-x64": "1.69.0", "@oxlint/binding-linux-arm-gnueabihf": "1.69.0", "@oxlint/binding-linux-arm-musleabihf": "1.69.0", "@oxlint/binding-linux-arm64-gnu": "1.69.0", "@oxlint/binding-linux-arm64-musl": "1.69.0", "@oxlint/binding-linux-ppc64-gnu": "1.69.0", "@oxlint/binding-linux-riscv64-gnu": "1.69.0", "@oxlint/binding-linux-riscv64-musl": "1.69.0", "@oxlint/binding-linux-s390x-gnu": "1.69.0", "@oxlint/binding-linux-x64-gnu": "1.69.0", "@oxlint/binding-linux-x64-musl": "1.69.0", "@oxlint/binding-openharmony-arm64": "1.69.0", "@oxlint/binding-win32-arm64-msvc": "1.69.0", "@oxlint/binding-win32-ia32-msvc": "1.69.0", "@oxlint/binding-win32-x64-msvc": "1.69.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-ypZkK/aDc5NQV8zIR6s2H2Tl3aNW8FmJ1m9+2qsaYuRenl8vgnHNCGwTHviWJdUQzglOlHFchgopdtGhSy17Rw=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -978,7 +1013,7 @@ "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=="], + "prettier": ["prettier@3.8.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q=="], "prettier-linter-helpers": ["prettier-linter-helpers@1.0.1", "", { "dependencies": { "fast-diff": "^1.1.2" } }, "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg=="], @@ -992,13 +1027,13 @@ "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], - "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], @@ -1038,6 +1073,8 @@ "rehype-stringify": ["rehype-stringify@10.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-html": "^9.0.0", "unified": "^11.0.0" } }, "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA=="], + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + "remark-mdx": ["remark-mdx@3.1.1", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg=="], "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], @@ -1074,9 +1111,9 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "shiki": ["shiki@4.1.0", "", { "dependencies": { "@shikijs/core": "4.1.0", "@shikijs/engine-javascript": "4.1.0", "@shikijs/engine-oniguruma": "4.1.0", "@shikijs/langs": "4.1.0", "@shikijs/themes": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q=="], + "shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="], - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], @@ -1110,9 +1147,9 @@ "string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="], - "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], + "string.prototype.trim": ["string.prototype.trim@1.2.11", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-object-atoms": "^1.1.2", "has-property-descriptors": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w=="], - "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="], + "string.prototype.trimend": ["string.prototype.trimend@1.0.10", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.2" } }, "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw=="], "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], @@ -1136,9 +1173,9 @@ "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=="], + "synckit": ["synckit@0.11.13", "", { "dependencies": { "@pkgr/core": "^0.3.6" } }, "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg=="], - "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], @@ -1158,13 +1195,13 @@ "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], - "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-array-length": ["typed-array-length@1.0.8", "", { "dependencies": { "call-bind": "^1.0.9", "for-each": "^0.3.5", "gopd": "^1.2.0", "is-typed-array": "^1.1.15", "possible-typed-array-names": "^1.1.0", "reflect.getprototypeof": "^1.0.10" } }, "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g=="], "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.4", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.4", "@typescript-eslint/parser": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/utils": "8.59.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ=="], + "typescript-eslint": ["typescript-eslint@8.61.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.61.0", "@typescript-eslint/parser": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/utils": "8.61.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw=="], "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=="], @@ -1224,7 +1261,7 @@ "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], - "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], + "which-typed-array": ["which-typed-array@1.1.22", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], @@ -1250,29 +1287,25 @@ "@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.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "@npmcli/config/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], "@npmcli/git/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "@npmcli/git/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "@npmcli/git/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], "@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.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "@npmcli/package-json/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], "@npmcli/promise-spawn/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], - "@testing-library/jest-dom/aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], - "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "@typescript-eslint/typescript-estree/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], - - "eslint-mdx/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + "@typescript-eslint/typescript-estree/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], "eslint-plugin-react/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], @@ -1280,13 +1313,15 @@ "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "normalize-package-data/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - "npm-install-checks/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "normalize-package-data/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], - "npm-package-arg/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "npm-install-checks/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], - "npm-pick-manifest/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "npm-package-arg/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + + "npm-pick-manifest/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], @@ -1296,7 +1331,7 @@ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - "sharp/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "sharp/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -1304,7 +1339,7 @@ "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "unified-engine/@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="], + "unified-engine/@types/node": ["@types/node@22.19.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA=="], "unified-engine/ignore": ["ignore@6.0.2", "", {}, "sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A=="], @@ -1326,9 +1361,7 @@ "@npmcli/promise-spawn/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], - "eslint-mdx/espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], - - "eslint-plugin-react/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + "eslint-plugin-react/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], "glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], diff --git a/crates/vespera/Cargo.toml b/crates/vespera/Cargo.toml index c8816469..5e168c42 100644 --- a/crates/vespera/Cargo.toml +++ b/crates/vespera/Cargo.toml @@ -22,6 +22,9 @@ default = [ cron = ["dep:tokio-cron-scheduler"] inprocess = ["dep:vespera_inprocess"] jni = ["inprocess", "dep:vespera_jni"] +# mimalloc as the cdylib's global allocator (see vespera_jni docs). +# Weak dep syntax: only applies when the `jni` feature enables vespera_jni. +mimalloc = ["vespera_jni?/mimalloc"] # Runtime validation: `#[derive(Schema)]` additionally emits # `impl garde::Validate` and the `Validated` extractor is enabled. # The `garde` crate is bundled internally and never named by user code. @@ -34,14 +37,17 @@ axum = { version = "0.8", features = ["multipart"] } axum-extra = { version = "0.12" } chrono = { version = "0.4", features = ["serde"] } tempfile = "3" +serde = { version = "1", features = ["derive"] } serde_json = "1" tower-layer = "0.3" tower-service = "0.3" tokio-cron-scheduler = { version = "0.15", optional = true } # Used by the `Serve` extension trait to bind a TcpListener and drive -# axum::serve. Default-on because virtually every axum user already -# has tokio in their dependency graph. -tokio = { version = "1", features = ["net", "rt"] } +# axum::serve, and by the multipart extractor to keep temp-file I/O +# off the async workers (`fs` + `io-util` for tokio::fs writes, +# `rt` for spawn_blocking). Default-on because virtually every axum +# user already has tokio in their dependency graph. +tokio = { version = "1", features = ["net", "rt", "fs", "io-util"] } vespera_inprocess = { workspace = true, optional = true } vespera_jni = { workspace = true, optional = true } # Hidden behind `validation` feature; re-exported via the private @@ -60,6 +66,8 @@ tower = { version = "0.5", features = ["util"] } # `vespera_inprocess::{register_app, dispatch_from_json}` directly so # they don't need the `inprocess` cargo feature to be enabled. vespera_inprocess = { workspace = true } +# Byte-snapshot testing for 422 validation envelope contract +insta = "1.48" [lints] workspace = true diff --git a/crates/vespera/src/multipart.rs b/crates/vespera/src/multipart.rs index 6f8f8ea3..def4ba6e 100644 --- a/crates/vespera/src/multipart.rs +++ b/crates/vespera/src/multipart.rs @@ -337,15 +337,19 @@ async fn read_field_data( let field_name = field.name().unwrap_or_default().to_string(); let data = if let Some(limit) = limit { - let mut buf = Vec::new(); + // Pre-size up to 64 KiB: avoids repeated doubling reallocations for + // typical fields without reserving huge buffers for large limits. + let mut buf = Vec::with_capacity(limit.min(64 * 1024)); while let Some(chunk) = field.chunk().await? { - buf.extend_from_slice(&chunk); - if buf.len() > limit { + // Reject BEFORE copying the over-limit chunk into the buffer — + // same acceptance condition (total <= limit), no wasted copy. + if buf.len().saturating_add(chunk.len()) > limit { return Err(TypedMultipartError::FieldTooLarge { field_name, limit_bytes: limit, }); } + buf.extend_from_slice(&chunk); } buf } else { @@ -360,10 +364,14 @@ async fn read_field_data( /// Accepted truthy values: `true`, `yes`, `y`, `1`, `on` /// Accepted falsy values: `false`, `no`, `n`, `0`, `off` fn str_to_bool(s: &str) -> Option { - match s.to_ascii_lowercase().as_str() { - "true" | "yes" | "y" | "1" | "on" => Some(true), - "false" | "no" | "n" | "0" | "off" => Some(false), - _ => None, + const TRUTHY: [&str; 5] = ["true", "yes", "y", "1", "on"]; + const FALSY: [&str; 5] = ["false", "no", "n", "0", "off"]; + if TRUTHY.iter().any(|t| s.eq_ignore_ascii_case(t)) { + Some(true) + } else if FALSY.iter().any(|f| s.eq_ignore_ascii_case(f)) { + Some(false) + } else { + None } } @@ -477,13 +485,34 @@ impl TryFromFieldWithState for tempfile::NamedTempFile { _state: &S, ) -> Result { let field_name = field.name().unwrap_or_default().to_string(); - let mut temp = Self::new().map_err(|e| TypedMultipartError::Other { + + // Temp-file creation is a blocking syscall — keep it off the + // async worker. `NamedTempFile` (not `tokio::fs::File`) is + // retained so cleanup-on-drop semantics survive. + let temp = tokio::task::spawn_blocking(Self::new) + .await + .map_err(|e| TypedMultipartError::Other { + source: e.to_string(), + })? + .map_err(|e| TypedMultipartError::Other { + source: e.to_string(), + })?; + + // Write through an independent async handle to the same file + // (tokio::fs routes writes to the blocking pool) so large + // uploads never stall the async executor. `temp` keeps + // ownership of the path + delete-on-drop guard. + let std_file = temp.reopen().map_err(|e| TypedMultipartError::Other { source: e.to_string(), })?; + let mut file = tokio::fs::File::from_std(std_file); let mut total = 0usize; while let Some(chunk) = field.chunk().await? { - total += chunk.len(); + // `saturating_add` (matching `read_field_data`) prevents a + // pathological chunk size from wrapping `total` and slipping + // past the limit check below. + total = total.saturating_add(chunk.len()); if let Some(limit) = limit_bytes && total > limit { @@ -492,12 +521,17 @@ impl TryFromFieldWithState for tempfile::NamedTempFile { limit_bytes: limit, }); } - std::io::Write::write_all(&mut temp, &chunk).map_err(|e| { - TypedMultipartError::Other { + tokio::io::AsyncWriteExt::write_all(&mut file, &chunk) + .await + .map_err(|e| TypedMultipartError::Other { source: e.to_string(), - } - })?; + })?; } + tokio::io::AsyncWriteExt::flush(&mut file) + .await + .map_err(|e| TypedMultipartError::Other { + source: e.to_string(), + })?; Ok(temp) } diff --git a/crates/vespera/src/serve.rs b/crates/vespera/src/serve.rs index e1f126c9..ea087599 100644 --- a/crates/vespera/src/serve.rs +++ b/crates/vespera/src/serve.rs @@ -2,14 +2,22 @@ //! with a one-liner. //! //! ```no_run -//! use vespera::{vespera, Serve}; +//! use vespera::Serve; //! //! #[tokio::main] //! async fn main() -> std::io::Result<()> { -//! vespera!(title = "My API").serve("0.0.0.0:3000").await +//! vespera::axum::Router::new().serve("0.0.0.0:3000").await //! } //! ``` //! +//! Pairs naturally with the [`vespera!`](vespera_macro::vespera) macro +//! (marked `ignore` because the macro scans the caller's `src/routes/` +//! at compile time, which doesn't exist in a doctest sandbox): +//! +//! ```ignore +//! vespera!(title = "My API").serve("0.0.0.0:3000").await +//! ``` +//! //! Equivalent to: //! //! ```ignore diff --git a/crates/vespera/src/validated.rs b/crates/vespera/src/validated.rs index c447f31e..fa3923b9 100644 --- a/crates/vespera/src/validated.rs +++ b/crates/vespera/src/validated.rs @@ -115,29 +115,41 @@ where /// /// Body shape: /// ```json -/// { "errors": [ { "path": "field.name", "message": "..." } ] } +/// { "errors": [ { "message": "...", "path": "field.name" } ] } /// ``` /// -/// We build the JSON via `serde_json::json!` (no extra `serde` derive -/// dep needed) so this module compiles with the bare `serde_json` -/// re-export already present on the `vespera` crate. +/// Field order inside each error object is `message` then `path` — +/// matching the alphabetical order produced by the previous +/// `serde_json::json!` implementation (which used a `BTreeMap` backend). +/// The envelope shape is a public contract locked by snapshot tests and +/// the JNI wire header hoisting logic in `vespera_inprocess`. fn build_validation_response(report: &::garde::Report) -> Response { - let errors: Vec<::serde_json::Value> = report + #[derive(serde::Serialize)] + struct ValidationErrorOut { + message: String, + path: String, + } + + #[derive(serde::Serialize)] + struct ValidationEnvelope { + errors: Vec, + } + + let errors: Vec = report .iter() - .map(|(path, err)| { - ::serde_json::json!({ - "path": path.to_string(), - "message": err.message(), - }) + .map(|(path, err)| ValidationErrorOut { + message: err.message().to_string(), + path: path.to_string(), }) .collect(); - let envelope = ::serde_json::json!({ "errors": errors }); - let body = envelope.to_string(); + + let body = ::serde_json::to_string(&ValidationEnvelope { errors }) + .unwrap_or_else(|_| r#"{"errors":[]}"#.to_owned()); let mut response = (StatusCode::UNPROCESSABLE_ENTITY, body).into_response(); response.headers_mut().insert( CONTENT_TYPE, - "application/json".parse().expect("static value parses"), + ::axum::http::HeaderValue::from_static("application/json"), ); response } diff --git a/crates/vespera/tests/snapshots/validated_extractor__validated_422_envelope_multi_error.snap b/crates/vespera/tests/snapshots/validated_extractor__validated_422_envelope_multi_error.snap new file mode 100644 index 00000000..c0ee5a61 --- /dev/null +++ b/crates/vespera/tests/snapshots/validated_extractor__validated_422_envelope_multi_error.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera/tests/validated_extractor.rs +expression: body_str +--- +{"errors":[{"message":"length is lower than 3","path":"title"},{"message":"length is lower than 1","path":"content"}]} diff --git a/crates/vespera/tests/validated_extractor.rs b/crates/vespera/tests/validated_extractor.rs index 9cf81856..ce5f56f9 100644 --- a/crates/vespera/tests/validated_extractor.rs +++ b/crates/vespera/tests/validated_extractor.rs @@ -29,20 +29,31 @@ fn router() -> Router { Router::new().route("/posts", post(create_post)) } +fn post_json_request(uri: &str, body: impl Into) -> Request { + Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .body(body.into()) + .unwrap() +} + async fn body_to_string(body: Body) -> String { let bytes = ::axum::body::to_bytes(body, usize::MAX).await.unwrap(); String::from_utf8(bytes.to_vec()).unwrap() } +fn assert_json_content_type(headers: &::axum::http::HeaderMap) { + assert_eq!( + headers.get("content-type").map(|v| v.to_str().unwrap()), + Some("application/json"), + ); +} + #[tokio::test] async fn valid_payload_returns_200() { let app = router(); - let req = Request::builder() - .method("POST") - .uri("/posts") - .header("content-type", "application/json") - .body(Body::from(r#"{"title":"My Post","content":"hello world"}"#)) - .unwrap(); + let req = post_json_request("/posts", r#"{"title":"My Post","content":"hello world"}"#); let res = app.oneshot(req).await.unwrap(); assert_eq!(res.status(), 200); @@ -52,21 +63,11 @@ async fn valid_payload_returns_200() { #[tokio::test] async fn short_title_returns_422_with_path_keyed_envelope() { let app = router(); - let req = Request::builder() - .method("POST") - .uri("/posts") - .header("content-type", "application/json") - .body(Body::from(r#"{"title":"X","content":"ok"}"#)) - .unwrap(); + let req = post_json_request("/posts", r#"{"title":"X","content":"ok"}"#); let res = app.oneshot(req).await.unwrap(); assert_eq!(res.status(), 422); - assert_eq!( - res.headers() - .get("content-type") - .map(|v| v.to_str().unwrap()), - Some("application/json"), - ); + assert_json_content_type(res.headers()); let body: ::serde_json::Value = ::serde_json::from_str(&body_to_string(res.into_body()).await).unwrap(); @@ -84,12 +85,7 @@ async fn short_title_returns_422_with_path_keyed_envelope() { #[tokio::test] async fn empty_content_returns_422() { let app = router(); - let req = Request::builder() - .method("POST") - .uri("/posts") - .header("content-type", "application/json") - .body(Body::from(r#"{"title":"Valid title","content":""}"#)) - .unwrap(); + let req = post_json_request("/posts", r#"{"title":"Valid title","content":""}"#); let res = app.oneshot(req).await.unwrap(); assert_eq!(res.status(), 422); @@ -103,12 +99,7 @@ async fn empty_content_returns_422() { #[tokio::test] async fn multiple_violations_all_appear_in_envelope() { let app = router(); - let req = Request::builder() - .method("POST") - .uri("/posts") - .header("content-type", "application/json") - .body(Body::from(r#"{"title":"X","content":""}"#)) - .unwrap(); + let req = post_json_request("/posts", r#"{"title":"X","content":""}"#); let res = app.oneshot(req).await.unwrap(); assert_eq!(res.status(), 422); @@ -127,12 +118,7 @@ async fn malformed_json_propagates_400_not_422() { // `Validated` must forward that rejection unchanged rather than // synthesizing a 422 from a non-existent garde report. let app = router(); - let req = Request::builder() - .method("POST") - .uri("/posts") - .header("content-type", "application/json") - .body(Body::from("not json")) - .unwrap(); + let req = post_json_request("/posts", "not json"); let res = app.oneshot(req).await.unwrap(); // Axum's Json extractor returns 400 (or 415 depending on cause) — @@ -219,12 +205,7 @@ async fn dispatch(app: Router, payload: ::serde_json::Value) -> (u16, ::serde_js let res = app.oneshot(req).await.unwrap(); let status = res.status().as_u16(); if status == 422 { - assert_eq!( - res.headers() - .get("content-type") - .map(|v| v.to_str().unwrap()), - Some("application/json"), - ); + assert_json_content_type(res.headers()); } let body: ::serde_json::Value = ::serde_json::from_str(&body_to_string(res.into_body()).await) .unwrap_or(::serde_json::Value::Null); @@ -312,12 +293,7 @@ async fn rule_range_minimum_violation_returns_422() { "ok" } let app = Router::new().route("/n", post(handler)); - let req = Request::builder() - .method("POST") - .uri("/n") - .header("content-type", "application/json") - .body(Body::from(r#"{"age":-1}"#)) - .unwrap(); + let req = post_json_request("/n", r#"{"age":-1}"#); let res = app.oneshot(req).await.unwrap(); assert_eq!(res.status(), 422); let body: ::serde_json::Value = @@ -392,3 +368,33 @@ async fn multiple_per_rule_violations_all_appear_in_envelope() { assert_envelope_has_field_error(&body, field); } } + +// ── byte-snapshot test: 422 validation envelope contract ──────────────── +// +// This test locks the EXACT serialized bytes of the 422 validation-error +// envelope produced by `Validated`. The snapshot proves byte-identity +// across refactors of `crates/vespera/src/validated.rs`. +// +// The envelope shape is a public contract: +// - Used by axum handlers (JSON response body) +// - Hoisted into JNI wire headers as `"validation_errors": [...]` +// - Consumed by Java decoders and client libraries +// +// Multi-error coverage: triggers 2+ field errors to verify the full +// envelope structure (path before message, array ordering, etc.). + +#[tokio::test] +async fn byte_snapshot_422_envelope_multi_error() { + let app = router(); + let req = post_json_request("/posts", r#"{"title":"X","content":""}"#); + + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), 422); + + let body_bytes = ::axum::body::to_bytes(res.into_body(), usize::MAX) + .await + .unwrap(); + let body_str = String::from_utf8(body_bytes.to_vec()).unwrap(); + + insta::assert_snapshot!("validated_422_envelope_multi_error", body_str); +} diff --git a/crates/vespera_core/src/schema.rs b/crates/vespera_core/src/schema.rs index c9e6b6fb..9cb39387 100644 --- a/crates/vespera_core/src/schema.rs +++ b/crates/vespera_core/src/schema.rs @@ -59,10 +59,21 @@ where { match value { Some(v) if v.fract() == 0.0 => { - // Practical OpenAPI constraints are well within i64 range + // Float→int casts saturate in Rust, so an out-of-range + // constraint (e.g. `1e20`) would silently become `i64::MAX` + // and corrupt the generated spec. Emit the integer form + // only when it round-trips exactly back to the original + // value; otherwise keep the `f64` rendering. #[allow(clippy::cast_possible_truncation)] let int_val = *v as i64; - serializer.serialize_some(&int_val) + // Exact round-trip check is intentional: we emit the integer + // form only when `i64 → f64` reproduces the original bits. + #[allow(clippy::cast_precision_loss, clippy::float_cmp)] + if int_val as f64 == *v { + serializer.serialize_some(&int_val) + } else { + serializer.serialize_some(v) + } } Some(v) => serializer.serialize_some(v), None => serializer.serialize_none(), @@ -503,6 +514,29 @@ mod tests { ); } + #[test] + fn serialize_out_of_i64_range_constraint_stays_float() { + // A whole-number constraint beyond i64 range must NOT saturate to + // i64::MAX — it stays a float so the spec keeps the real value. + let schema = Schema { + maximum: Some(1e20), + ..Schema::number() + }; + let json = serde_json::to_string(&schema).unwrap(); + assert!( + !json.contains(&i64::MAX.to_string()), + "must not saturate to i64::MAX: {json}" + ); + // Parse back: the constraint value must be preserved exactly, + // regardless of serde's float formatting. + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!( + parsed["maximum"].as_f64(), + Some(1e20), + "constraint value must be preserved: {json}" + ); + } + #[test] fn serialize_multiple_of_whole_number_as_integer() { let schema = Schema { diff --git a/crates/vespera_inprocess/benches/dispatch.rs b/crates/vespera_inprocess/benches/dispatch.rs index b6e63bf0..b70d1078 100644 --- a/crates/vespera_inprocess/benches/dispatch.rs +++ b/crates/vespera_inprocess/benches/dispatch.rs @@ -1,6 +1,6 @@ //! Criterion benchmarks for the in-process dispatch surface. //! -//! Three groups: +//! Five groups: //! //! - `router_path`: `Router::clone()` of a pre-built router (post-P1) //! vs rebuilding the router from a factory closure (pre-P1, simulated). @@ -8,22 +8,34 @@ //! vs `dispatch_typed(router, &env)` which clones internally (pre-P2). //! - `wire_path`: end-to-end `dispatch_from_bytes` — wire-format //! round-trip including header JSON parse + body byte handling. +//! - `headers_path`: `dispatch_from_bytes` against a route that sets +//! many response headers (incl. multi-value `set-cookie`) — +//! isolates `collect_header_map` + wire header serialisation cost. +//! - `streaming_path`: `dispatch_streaming_async` (response +//! streaming) and `dispatch_bidirectional_streaming` (request + +//! response streaming through the mpsc channel + spawn_blocking +//! producer) — gates the chunk-size / channel-capacity work. Also +//! includes a no-body-poll route to isolate lazy request-pull setup. //! //! Scaling axes: //! - `route_count`: 10 / 100 / 500 routes (Router-build dominance). //! - `body_kb`: 1 / 64 / 1024 KB request bodies (body-clone dominance). use std::collections::HashMap; +use std::sync::Mutex; use axum::{ Json, Router, + http::{HeaderMap, HeaderName}, + response::{IntoResponse, Response}, routing::{get, post}, }; use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; use serde::{Deserialize, Serialize}; use tokio::runtime::Runtime; use vespera_inprocess::{ - RequestEnvelope, dispatch_from_bytes, dispatch_owned, dispatch_typed, register_app, + RequestChunk, RequestEnvelope, dispatch_bidirectional_streaming, dispatch_from_bytes, + dispatch_owned, dispatch_streaming_async, dispatch_typed, register_app, }; // ── Test fixtures ──────────────────────────────────────────────────── @@ -41,10 +53,56 @@ async fn handler_echo(Json(payload): Json) -> Json { Json(payload) } +/// Echo raw request-body bytes back — used by the streaming benches +/// so request chunks flow through the handler unchanged. +async fn handler_echo_bytes(body: bytes::Bytes) -> bytes::Bytes { + body +} + +/// Return without polling the request body. This isolates the cost of +/// bidirectional request-pull setup for handlers that do not need the +/// body at all. +async fn handler_discard_body() -> &'static str { + "ok" +} + +/// Respond with a realistic header set: 10 single-value headers plus +/// a 3-value `set-cookie` — exercises `collect_header_map`'s Vacant +/// and Occupied paths and the wire header JSON serialisation. +async fn handler_many_headers() -> Response { + let mut headers = HeaderMap::new(); + for (name, value) in [ + ("cache-control", "no-store"), + ("etag", "\"abc123def456\""), + ("vary", "accept-encoding"), + ("x-content-type-options", "nosniff"), + ("x-frame-options", "DENY"), + ("x-request-id", "01HV2N3M4P5Q6R7S8T9V0W1X2Y"), + ("x-trace-id", "4bf92f3577b34da6a3ce929d0e0e4736"), + ("access-control-allow-origin", "*"), + ("strict-transport-security", "max-age=63072000"), + ("content-language", "en"), + ] { + headers.insert( + HeaderName::from_static(name), + value.parse().expect("static header value"), + ); + } + let cookie = HeaderName::from_static("set-cookie"); + headers.append(cookie.clone(), "session=s1; HttpOnly".parse().unwrap()); + headers.append(cookie.clone(), "theme=dark; Path=/".parse().unwrap()); + headers.append(cookie, "lang=en; Path=/".parse().unwrap()); + (headers, "ok").into_response() +} + /// Build a router with `n_routes` distinct GET endpoints plus one /// `POST /echo` that echoes the request body. fn build_router(n_routes: usize) -> Router { - let mut router = Router::new().route("/echo", post(handler_echo)); + let mut router = Router::new() + .route("/echo", post(handler_echo)) + .route("/echo/bytes", post(handler_echo_bytes)) + .route("/discard", post(handler_discard_body)) + .route("/headers", get(handler_many_headers)); for i in 0..n_routes { let path = format!("/r{i}"); router = router.route(&path, get(handler_get)); @@ -66,26 +124,60 @@ fn make_envelope(body_kb: usize) -> RequestEnvelope { } } +/// Assemble `[u32 BE header_len | header JSON | body]` wire bytes. +fn assemble_wire(method: &str, path: &str, content_type: Option<&str>, body: &[u8]) -> Vec { + assemble_wire_for_app(method, path, content_type, None, body) +} + +/// `assemble_wire` with an optional `"app"` wire-header field. +fn assemble_wire_for_app( + method: &str, + path: &str, + content_type: Option<&str>, + app: Option<&str>, + body: &[u8], +) -> Vec { + let mut header = content_type.map_or_else( + || serde_json::json!({ "v": 1, "method": method, "path": path }), + |ct| { + serde_json::json!({ + "v": 1, + "method": method, + "path": path, + "headers": {"content-type": ct}, + }) + }, + ); + if let Some(app) = app { + header["app"] = serde_json::Value::String(app.to_owned()); + } + let header_bytes = serde_json::to_vec(&header).unwrap(); + let header_len = u32::try_from(header_bytes.len()).unwrap(); + let mut wire = Vec::with_capacity(4 + header_bytes.len() + body.len()); + wire.extend_from_slice(&header_len.to_be_bytes()); + wire.extend_from_slice(&header_bytes); + wire.extend_from_slice(body); + wire +} + /// Wire-format request payload for the `dispatch_from_bytes` bench. fn make_wire_request(body_kb: usize) -> Vec { let body_str = serde_json::to_string(&Echo { body: "x".repeat(body_kb * 1024), }) .unwrap(); - let header = serde_json::json!({ - "v": 1, - "method": "POST", - "path": "/echo", - "headers": {"content-type": "application/json"}, - }); - let header_bytes = serde_json::to_vec(&header).unwrap(); - let header_len = u32::try_from(header_bytes.len()).unwrap(); - let body_bytes = body_str.as_bytes(); - let mut wire = Vec::with_capacity(4 + header_bytes.len() + body_bytes.len()); - wire.extend_from_slice(&header_len.to_be_bytes()); - wire.extend_from_slice(&header_bytes); - wire.extend_from_slice(body_bytes); - wire + assemble_wire( + "POST", + "/echo", + Some("application/json"), + body_str.as_bytes(), + ) +} + +/// Register the shared bench app exactly once per process. +fn install_bench_app() { + static INIT: std::sync::Once = std::sync::Once::new(); + INIT.call_once(|| register_app(|| build_router(100))); } // ── Benchmarks ─────────────────────────────────────────────────────── @@ -160,8 +252,7 @@ fn bench_dispatch_path(c: &mut Criterion) { /// response bytes via the registered app. Measures the realistic FFI /// cost the JNI bridge pays. fn bench_wire_path(c: &mut Criterion) { - static INIT: std::sync::Once = std::sync::Once::new(); - INIT.call_once(|| register_app(|| build_router(100))); + install_bench_app(); let runtime = Runtime::new().expect("tokio runtime"); let mut group = c.benchmark_group("wire_path"); @@ -183,10 +274,221 @@ fn bench_wire_path(c: &mut Criterion) { drop(runtime); } +/// P2 isolation (within-run A/B): default-app resolution via the +/// lock-free `OnceLock` fast path vs named-app resolution through the +/// `RwLock` slow path. Identical router, identical wire +/// request shape — the only difference is the `"app"` header field. +fn bench_resolve_path(c: &mut Criterion) { + static INIT_NAMED: std::sync::Once = std::sync::Once::new(); + + install_bench_app(); + INIT_NAMED + .call_once(|| vespera_inprocess::register_app_named("bench-named", || build_router(100))); + + let runtime = Runtime::new().expect("tokio runtime"); + let mut group = c.benchmark_group("resolve_path"); + + let wire_default = assemble_wire_for_app("GET", "/r0", None, None, &[]); + group.bench_function("default_oncelock_fast_path", |b| { + b.iter(|| dispatch_from_bytes(wire_default.clone(), &runtime)); + }); + + let wire_named = assemble_wire_for_app("GET", "/r0", None, Some("bench-named"), &[]); + group.bench_function("named_rwlock_slow_path", |b| { + b.iter(|| dispatch_from_bytes(wire_named.clone(), &runtime)); + }); + + group.finish(); + drop(runtime); +} + +/// P2 contention measurement: concurrent `dispatch_from_bytes` from +/// many OS threads against one shared multi-thread runtime. +/// +/// `default` resolves through the lock-free `OnceLock` fast path; +/// `named` goes through the `RwLock`. Under reader pressure +/// the RwLock path can park threads — the delta between the two +/// captures exactly what the single-threaded `resolve_path` group +/// cannot. Excluded from the CI regression gate (heavily +/// scheduler-dependent); run locally for the numbers. +fn bench_contended_path(c: &mut Criterion) { + static INIT_NAMED: std::sync::Once = std::sync::Once::new(); + + install_bench_app(); + INIT_NAMED + .call_once(|| vespera_inprocess::register_app_named("bench-named", || build_router(100))); + + let runtime = std::sync::Arc::new(Runtime::new().expect("tokio runtime")); + let mut group = c.benchmark_group("contended_path"); + + for &threads in &[8_usize, 32] { + for (label, app) in [ + ("default_oncelock", None), + ("named_rwlock", Some("bench-named")), + ] { + let wire = assemble_wire_for_app("GET", "/r0", None, app, &[]); + group.bench_with_input(BenchmarkId::new(label, threads), &threads, |b, &threads| { + b.iter_custom(|iters| { + let per_thread = usize::try_from(iters) + .unwrap_or(usize::MAX) + .div_ceil(threads); + let start = std::time::Instant::now(); + std::thread::scope(|scope| { + for _ in 0..threads { + let wire = wire.clone(); + let runtime = std::sync::Arc::clone(&runtime); + scope.spawn(move || { + for _ in 0..per_thread { + std::hint::black_box(dispatch_from_bytes( + wire.clone(), + &runtime, + )); + } + }); + } + }); + start.elapsed() + }); + }); + } + } + + group.finish(); +} + +/// P4 isolation: response with 10 single-value headers + 3-value +/// `set-cookie` — dominated by `collect_header_map` allocations and +/// wire header JSON serialisation rather than body handling. +fn bench_headers_path(c: &mut Criterion) { + install_bench_app(); + + let runtime = Runtime::new().expect("tokio runtime"); + let wire = assemble_wire("GET", "/headers", None, &[]); + let mut group = c.benchmark_group("headers_path"); + + group.bench_function("many_headers_roundtrip", |b| { + b.iter(|| dispatch_from_bytes(wire.clone(), &runtime)); + }); + + group.finish(); + drop(runtime); +} + +/// P1/P3 isolation: streaming dispatch throughput. +/// +/// - `response_streaming`: full body in the request, response drained +/// through the `on_chunk` callback. +/// - `bidirectional`: request body fed through `pull_chunk` in +/// [`vespera_inprocess::DEFAULT_STREAMING_CHUNK_BYTES`] pieces +/// (mirrors the JNI `InputStream` reader), response drained through +/// `on_chunk` — exercises the bounded mpsc channel and the +/// `spawn_blocking` producer. +fn bench_streaming_path(c: &mut Criterion) { + install_bench_app(); + + let runtime = Runtime::new().expect("tokio runtime"); + let mut group = c.benchmark_group("streaming_path"); + + for &body_kb in &[64_usize, 1024] { + let payload = vec![0xA5u8; body_kb * 1024]; + group.throughput(Throughput::Bytes((body_kb * 1024) as u64)); + + let wire = assemble_wire( + "POST", + "/echo/bytes", + Some("application/octet-stream"), + &payload, + ); + group.bench_with_input( + BenchmarkId::new("response_streaming", body_kb), + &body_kb, + |b, _| { + b.iter(|| { + let mut sink = 0usize; + runtime.block_on(dispatch_streaming_async(wire.clone(), |chunk| { + sink += chunk.len(); + })); + sink + }); + }, + ); + + let header_only = + assemble_wire("POST", "/echo/bytes", Some("application/octet-stream"), &[]); + let pull_chunk_size = vespera_inprocess::DEFAULT_STREAMING_CHUNK_BYTES; + let request_chunks: Vec> = payload + .chunks(pull_chunk_size) + .map(<[u8]>::to_vec) + .collect(); + group.bench_with_input( + BenchmarkId::new("bidirectional", body_kb), + &body_kb, + |b, _| { + b.iter(|| { + let chunks_iter = Mutex::new(request_chunks.clone().into_iter()); + let pull = move || -> RequestChunk { + chunks_iter + .lock() + .unwrap() + .next() + .map_or(RequestChunk::End, RequestChunk::Data) + }; + let mut sink = 0usize; + runtime.block_on(dispatch_bidirectional_streaming( + header_only.clone(), + pull, + |chunk| { + sink += chunk.len(); + }, + )); + sink + }); + }, + ); + + let discard_header_only = + assemble_wire("POST", "/discard", Some("application/octet-stream"), &[]); + group.bench_with_input( + BenchmarkId::new("bidirectional_no_body_poll", body_kb), + &body_kb, + |b, _| { + b.iter(|| { + let remaining = Mutex::new(body_kb * 1024); + let pull = move || -> RequestChunk { + let mut remaining = remaining.lock().unwrap(); + if *remaining == 0 { + return RequestChunk::End; + } + let len = (*remaining).min(pull_chunk_size); + *remaining -= len; + RequestChunk::Data(vec![0xA5u8; len]) + }; + let mut sink = 0usize; + runtime.block_on(dispatch_bidirectional_streaming( + discard_header_only.clone(), + pull, + |chunk| { + sink += chunk.len(); + }, + )); + sink + }); + }, + ); + } + + group.finish(); + drop(runtime); +} + criterion_group!( benches, bench_router_path, bench_dispatch_path, - bench_wire_path + bench_wire_path, + bench_resolve_path, + bench_contended_path, + bench_headers_path, + bench_streaming_path ); criterion_main!(benches); diff --git a/crates/vespera_inprocess/src/config.rs b/crates/vespera_inprocess/src/config.rs new file mode 100644 index 00000000..b8bd1298 --- /dev/null +++ b/crates/vespera_inprocess/src/config.rs @@ -0,0 +1,209 @@ +//! Process-wide streaming configuration (chunk size, channel +//! capacity) — resolved once via `OnceLock`: setter > env > default. + +use std::sync::OnceLock; + +// ── Streaming Configuration ────────────────────────────────────────── + +/// Default per-chunk buffer size for streaming dispatches (256 KiB). +/// +/// Large enough to amortise per-chunk FFI overhead (JNI region copy + +/// `OutputStream.write` call per chunk), small enough to keep memory +/// bounded for multi-GB streams. Raised from 64 KiB to 256 KiB +/// because measured streaming throughput improves ~25 % (11.6 → 14.5 +/// GB/s) at the cost of an extra 192 KiB of per-stream buffer per +/// direction — both still well within "low-single-digit MiB resident +/// per stream" for multi-GB transfers. Tune down via +/// `set_streaming_chunk_bytes`, the `VESPERA_STREAMING_CHUNK_BYTES` +/// env var, or `VesperaBridge.configureStreaming(...)` when memory is +/// tighter than throughput. +pub const DEFAULT_STREAMING_CHUNK_BYTES: usize = 256 * 1024; + +/// Default capacity (slots) of the bounded mpsc channel that feeds +/// request-body chunks into axum during bidirectional streaming. +pub const DEFAULT_STREAMING_CHANNEL_CAPACITY: usize = 16; + +const MIN_STREAMING_CHUNK_BYTES: usize = 4 * 1024; +const MAX_STREAMING_CHUNK_BYTES: usize = 8 * 1024 * 1024; +const MIN_STREAMING_CHANNEL_CAPACITY: usize = 1; +const MAX_STREAMING_CHANNEL_CAPACITY: usize = 1024; + +static STREAMING_CHUNK_BYTES: OnceLock = OnceLock::new(); +static STREAMING_CHANNEL_CAPACITY: OnceLock = OnceLock::new(); + +/// Parse an optional config string into a clamped `usize`, falling +/// back to `default` when absent or unparseable. +fn parse_config_value(raw: Option<&str>, default: usize, min: usize, max: usize) -> usize { + raw.and_then(|s| s.trim().parse::().ok()) + .map_or(default, |v| v.clamp(min, max)) +} + +/// Effective per-chunk buffer size for streaming dispatches. +/// +/// Resolution order (first hit wins, then cached for the process +/// lifetime via `OnceLock` — a single atomic load per call): +/// +/// 1. [`set_streaming_chunk_bytes`] called before the first read +/// 2. `VESPERA_STREAMING_CHUNK_BYTES` environment variable +/// 3. [`DEFAULT_STREAMING_CHUNK_BYTES`] (256 KiB) +/// +/// Values are clamped to `[4 KiB, 8 MiB]`. +#[must_use] +#[inline] +pub fn streaming_chunk_bytes() -> usize { + *STREAMING_CHUNK_BYTES.get_or_init(|| { + parse_config_value( + std::env::var("VESPERA_STREAMING_CHUNK_BYTES") + .ok() + .as_deref(), + DEFAULT_STREAMING_CHUNK_BYTES, + MIN_STREAMING_CHUNK_BYTES, + MAX_STREAMING_CHUNK_BYTES, + ) + }) +} + +/// Override the streaming chunk size **before the first dispatch** +/// (e.g. from a host-language configuration hook at init time). +/// +/// Returns `false` when the value was already fixed — either by a +/// previous call or because a dispatch has already read it. The +/// supplied value is clamped to `[4 KiB, 8 MiB]`. +pub fn set_streaming_chunk_bytes(bytes: usize) -> bool { + STREAMING_CHUNK_BYTES + .set(bytes.clamp(MIN_STREAMING_CHUNK_BYTES, MAX_STREAMING_CHUNK_BYTES)) + .is_ok() +} + +/// Effective bound (slots) of the bidirectional request-body channel. +/// +/// Same resolution order as [`streaming_chunk_bytes`]: +/// [`set_streaming_channel_capacity`] > +/// `VESPERA_STREAMING_CHANNEL_CAPACITY` env var > +/// [`DEFAULT_STREAMING_CHANNEL_CAPACITY`] (16). Clamped to +/// `[1, 1024]`. +#[must_use] +#[inline] +pub fn streaming_channel_capacity() -> usize { + *STREAMING_CHANNEL_CAPACITY.get_or_init(|| { + parse_config_value( + std::env::var("VESPERA_STREAMING_CHANNEL_CAPACITY") + .ok() + .as_deref(), + DEFAULT_STREAMING_CHANNEL_CAPACITY, + MIN_STREAMING_CHANNEL_CAPACITY, + MAX_STREAMING_CHANNEL_CAPACITY, + ) + }) +} + +/// Override the bidirectional channel capacity **before the first +/// dispatch**. Returns `false` when already fixed. Clamped to +/// `[1, 1024]`. +pub fn set_streaming_channel_capacity(slots: usize) -> bool { + STREAMING_CHANNEL_CAPACITY + .set(slots.clamp( + MIN_STREAMING_CHANNEL_CAPACITY, + MAX_STREAMING_CHANNEL_CAPACITY, + )) + .is_ok() +} + +// ── Request-size ingress cap ───────────────────────────────────────── + +static MAX_REQUEST_BYTES: OnceLock = OnceLock::new(); + +/// Maximum accepted request size (header + body) for the **buffered** +/// dispatch entry points, in bytes. `0` (the default) means +/// **unlimited**, preserving prior behaviour. +/// +/// Resolution order (first hit wins, then cached for the process +/// lifetime): [`set_max_request_bytes`] > `VESPERA_MAX_REQUEST_BYTES` +/// env var > `0` (unlimited). +/// +/// This is a defense-in-depth ingress cap: a caller that bypasses the +/// autoconfigured Spring proxy (which already routes large bodies to +/// streaming) and feeds a multi-GB body straight into `dispatchBytes` / +/// `dispatchAsync` / `dispatchDirect` would otherwise force a full +/// resident copy. When set, oversized requests get a `413` wire +/// response **before** the body is allocated. The **streaming** +/// entry points are intentionally exempt — they are `O(chunk)` RAM and +/// are the correct path for legitimately large payloads. +#[must_use] +#[inline] +pub fn max_request_bytes() -> usize { + *MAX_REQUEST_BYTES.get_or_init(|| { + std::env::var("VESPERA_MAX_REQUEST_BYTES") + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(0) + }) +} + +/// Override the request-size cap **before the first dispatch**. +/// `0` means unlimited. Returns `false` when the value was already +/// fixed (a previous call or a dispatch already read it). +pub fn set_max_request_bytes(bytes: usize) -> bool { + MAX_REQUEST_BYTES.set(bytes).is_ok() +} + +/// Whether a request of `len` bytes exceeds the configured cap. +/// Always `false` when the cap is unlimited (`0`). +#[must_use] +#[inline] +pub fn request_exceeds_limit(len: usize) -> bool { + let max = max_request_bytes(); + max != 0 && len > max +} + +#[cfg(test)] +mod tests { + use super::{ + DEFAULT_STREAMING_CHANNEL_CAPACITY, DEFAULT_STREAMING_CHUNK_BYTES, parse_config_value, + }; + + #[test] + fn absent_value_yields_default() { + assert_eq!( + parse_config_value(None, DEFAULT_STREAMING_CHUNK_BYTES, 4096, 8 << 20), + DEFAULT_STREAMING_CHUNK_BYTES + ); + } + + #[test] + fn unparseable_value_yields_default() { + for raw in ["", "abc", "-1", "64KiB", "1.5"] { + assert_eq!( + parse_config_value(Some(raw), DEFAULT_STREAMING_CHANNEL_CAPACITY, 1, 1024), + DEFAULT_STREAMING_CHANNEL_CAPACITY, + "raw = {raw:?}" + ); + } + } + + // The hardcoded `262144` below is the current + // `DEFAULT_STREAMING_CHUNK_BYTES` (256 KiB). These tests cover + // `parse_config_value`'s parsing/clamp behaviour, not the default + // constant directly — but we keep the representative value in + // sync with the real default so any future bump only needs one + // edit per call site. Bumped from 65536 (64 KiB) when the + // chunk-size default was raised to 256 KiB for +25 % streaming + // throughput. + #[test] + fn valid_value_is_used_and_whitespace_tolerated() { + assert_eq!( + parse_config_value(Some("131072"), 262_144, 4096, 8 << 20), + 131_072 + ); + assert_eq!(parse_config_value(Some(" 64 "), 16, 1, 1024), 64); + } + + #[test] + fn out_of_range_values_are_clamped() { + assert_eq!(parse_config_value(Some("1"), 262_144, 4096, 8 << 20), 4096); + assert_eq!( + parse_config_value(Some("999999999"), 262_144, 4096, 8 << 20), + 8 << 20 + ); + } +} diff --git a/crates/vespera_inprocess/src/dispatch.rs b/crates/vespera_inprocess/src/dispatch.rs new file mode 100644 index 00000000..98646c32 --- /dev/null +++ b/crates/vespera_inprocess/src/dispatch.rs @@ -0,0 +1,338 @@ +//! Public dispatch entry points: the direct (text envelope) API, the +//! binary wire API, and the direct-write (caller buffer) API. + +use std::collections::BTreeMap; + +use axum::body::Body; +use bytes::Bytes; +use http_body_util::BodyExt; + +use crate::Router; +use crate::envelope::{RequestEnvelope, ResponseEnvelope, ResponseMetadata}; +use crate::internal::{dispatch_and_split, dispatch_parts, to_response_envelope_text}; +use crate::registry::resolve_app_router; +use crate::wire::{ + WIRE_VERSION, build_wire_header_bytes, error_wire, parse_wire_header, split_wire_request, + to_wire_bytes, +}; + +// ── Dispatch (direct API — backward compatible) ────────────────────── + +/// Dispatch a [`RequestEnvelope`] through an axum [`Router`] and +/// return the serialised [`ResponseEnvelope`] JSON. +/// +/// This borrows the envelope and clones its owned fields before +/// passing them to the hot path. Callers that already own a +/// [`RequestEnvelope`] should prefer [`dispatch_owned`] to skip the +/// clone. +pub async fn dispatch(router: Router, envelope: &RequestEnvelope) -> String { + let result = dispatch_owned(router, envelope.clone()).await; + serde_json::to_string(&result).expect("ResponseEnvelope serialization is infallible") +} + +/// Typed dispatch — returns a [`ResponseEnvelope`] directly. +/// +/// See [`dispatch`] for the clone trade-off; prefer [`dispatch_owned`] +/// when the envelope is already owned. +pub async fn dispatch_typed(router: Router, envelope: &RequestEnvelope) -> ResponseEnvelope { + dispatch_owned(router, envelope.clone()).await +} + +/// Dispatch an owned [`RequestEnvelope`] — moves the envelope into +/// the HTTP request so the body, path, and headers are never cloned. +/// +/// This is the hot path used by callers (e.g. custom FFI transports) +/// that already own a freshly built envelope. +pub async fn dispatch_owned(router: Router, envelope: RequestEnvelope) -> ResponseEnvelope { + let RequestEnvelope { + method, + path, + query, + headers, + body, + } = envelope; + let parts = match dispatch_parts( + router, + &method, + &path, + &query, + headers.iter().map(|(k, v)| (k.as_str(), v.as_str())), + Bytes::from(body), + ) + .await + { + Ok(parts) => parts, + Err((status, msg)) => { + return ResponseEnvelope { + status, + headers: BTreeMap::new(), + body: msg, + metadata: ResponseMetadata::current(), + }; + } + }; + to_response_envelope_text(parts) +} + +// ── Binary Wire API ────────────────────────────────────────────────── + +/// Dispatch a wire-format request through the registered app and +/// return a wire-format response. +/// +/// Wire format: +/// ```text +/// bytes 0..4 : u32 BE = header_json byte length N +/// bytes 4..4+N : UTF-8 JSON +/// (request) { "v":1, "method", "path", +/// "query"?, "headers"? } +/// (response) { "v":1, "status", "headers", +/// "metadata" } +/// bytes 4+N..end : raw body bytes (UTF-8 text or binary — +/// no encoding applied) +/// ``` +/// +/// All failure modes return a valid wire-format response (length- +/// prefixed) so the caller's decoder never has to special-case +/// errors. Specifically: +/// +/// * input shorter than 4 bytes → 400 with explanatory body +/// * `header_len` exceeds input → 400 +/// * header JSON parse failure → 400 +/// * wire version mismatch → 400 +/// * invalid app name → 400 +/// * unknown HTTP method → 405 +/// * no app registered under the requested name → 404 +/// * router/handler errors → surfaced verbatim as response wire +pub fn dispatch_from_bytes(input: Vec, runtime: &tokio::runtime::Runtime) -> Vec { + runtime.block_on(dispatch_from_bytes_async(input)) +} + +/// Async sibling of [`dispatch_from_bytes`]. Use this when the caller +/// is already inside a Tokio runtime (e.g. an axum handler embedding +/// another vespera router, or a tokio-spawned task in the JNI bridge's +/// async dispatch path). +/// +/// All failure modes return a valid wire-format response (same +/// guarantees as [`dispatch_from_bytes`]), including `404` when no app +/// is registered under the requested name. +pub async fn dispatch_from_bytes_async(input: Vec) -> Vec { + // Ingress cap (defense-in-depth): reject an oversized buffered + // request with 413 before doing any further work. Unlimited by + // default (see `max_request_bytes`); streaming paths are exempt. + if crate::config::request_exceeds_limit(input.len()) { + return error_wire( + 413, + &format!( + "request size {} bytes exceeds configured maximum of {} bytes", + input.len(), + crate::config::max_request_bytes() + ), + ); + } + // Wire-level checks next: malformed input must report parse + // errors regardless of whether an app is registered. + let (header_bytes, body_bytes) = match split_wire_request(input) { + Ok(parts) => parts, + Err(msg) => return error_wire(400, &msg), + }; + let header = match parse_wire_header(&header_bytes) { + Ok(h) => h, + Err(msg) => return error_wire(400, &msg), + }; + if header.v != WIRE_VERSION { + return error_wire( + 400, + &format!( + "unsupported wire version: got {}, expected {WIRE_VERSION}", + header.v + ), + ); + } + let router = match resolve_app_router(&header) { + Ok(r) => r, + Err(wire) => return wire, + }; + let parts = match dispatch_parts( + router, + &header.method, + &header.path, + &header.query, + header.headers.iter().map(|(k, v)| (k.as_ref(), v.as_ref())), + body_bytes, + ) + .await + { + Ok(parts) => parts, + Err((status, msg)) => return error_wire(status, &msg), + }; + to_wire_bytes(parts) +} + +/// Outcome of [`dispatch_into_async`] / [`dispatch_into`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DirectWriteResult { + /// A complete wire response occupies `out[0..n]`. + Complete(usize), + /// The response needs `required` bytes and `out` was too small. + /// `out` contents are **undefined** (a prefix may have been + /// written). `required` is exact — a retry with a buffer of at + /// least this size succeeds, but **re-runs the handler**. + Overflow(usize), +} + +/// Sync wrapper around [`dispatch_into_async`] for FFI callers that +/// own a [`tokio::runtime::Runtime`]. +pub fn dispatch_into( + input: Vec, + out: &mut [u8], + runtime: &tokio::runtime::Runtime, +) -> DirectWriteResult { + runtime.block_on(dispatch_into_async(input, out)) +} + +/// Dispatch a wire-format request and write the wire response +/// **directly into `out`** — the zero-materialisation sibling of +/// [`dispatch_from_bytes_async`]. +/// +/// On the success path the response is never assembled in an +/// intermediate `Vec`: the wire header is written to `out[0..h]` as +/// soon as axum produces status + headers, then each body frame is +/// copied straight to its final offset. Compared with +/// `dispatch_from_bytes_async` + caller-side copy, this removes one +/// full response memcpy and the response-sized allocation. +/// +/// # Exceptions to direct writing +/// +/// * **`422` responses** are materialised first so the +/// `validation_errors` hoisting into the wire header (see +/// [`dispatch_from_bytes`]) is preserved byte-for-byte — validation +/// failures are tiny and cold, correctness wins. +/// * **Pre-dispatch errors** (malformed wire, bad version, unknown +/// app, invalid method) write the small `error_wire` response. +/// +/// # Overflow semantics +/// +/// If `out` is too small the body stream is still drained (counting, +/// not writing) so [`DirectWriteResult::Overflow`] reports the +/// **exact** required size. The handler has already run; retrying +/// runs it again — callers must gate retries on idempotency. +pub async fn dispatch_into_async(input: Vec, out: &mut [u8]) -> DirectWriteResult { + // Ingress cap (defense-in-depth) — same policy as + // `dispatch_from_bytes_async`; 413 written into the caller buffer. + if crate::config::request_exceeds_limit(input.len()) { + return write_wire_into( + out, + &error_wire( + 413, + &format!( + "request size {} bytes exceeds configured maximum of {} bytes", + input.len(), + crate::config::max_request_bytes() + ), + ), + ); + } + let (header_bytes, body_bytes) = match split_wire_request(input) { + Ok(parts) => parts, + Err(msg) => return write_wire_into(out, &error_wire(400, &msg)), + }; + let header = match parse_wire_header(&header_bytes) { + Ok(h) => h, + Err(msg) => return write_wire_into(out, &error_wire(400, &msg)), + }; + if header.v != WIRE_VERSION { + return write_wire_into( + out, + &error_wire( + 400, + &format!( + "unsupported wire version: got {}, expected {WIRE_VERSION}", + header.v + ), + ), + ); + } + let router = match resolve_app_router(&header) { + Ok(r) => r, + Err(wire) => return write_wire_into(out, &wire), + }; + + // Mirror dispatch_parts' Content-Type defaulting (body present, no + // content-type → application/json) so the direct-write path is + // request-compatible with dispatch_from_bytes. The body's + // emptiness is known here (unlike the streaming callers), so the + // default is applied on the request builder — no map insert, no + // String allocations. + let default_json_content_type = !body_bytes.is_empty() + && !header + .headers + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("content-type")); + + let (status, headers, metadata, mut body) = match dispatch_and_split( + router, + &header.method, + &header.path, + &header.query, + header.headers.iter().map(|(k, v)| (k.as_ref(), v.as_ref())), + Body::from(body_bytes), + default_json_content_type, + ) + .await + { + Ok(parts) => parts, + Err((status, msg)) => return write_wire_into(out, &error_wire(status, &msg)), + }; + + if status == 422 { + // Materialise to preserve validation_errors hoisting in the + // wire header — identical bytes to dispatch_from_bytes. + let body_bytes = body + .collect() + .await + .map(http_body_util::Collected::to_bytes) + .unwrap_or_default(); + let wire = to_wire_bytes((status, headers, body_bytes, metadata)); + return write_wire_into(out, &wire); + } + + let header_bytes = build_wire_header_bytes(status, &headers, &metadata); + let mut written = 0usize; + if header_bytes.len() <= out.len() { + out[..header_bytes.len()].copy_from_slice(&header_bytes); + written = header_bytes.len(); + } + let mut required = header_bytes.len(); + + while let Some(Ok(frame)) = body.frame().await { + if let Some(data) = frame.data_ref() + && !data.is_empty() + { + let len = data.len(); + // Write only while the output is still contiguous + // (`written == required` ⇒ nothing has been skipped yet). + if written == required && written + len <= out.len() { + out[written..written + len].copy_from_slice(data); + written += len; + } + required += len; + } + } + + if written == required { + DirectWriteResult::Complete(written) + } else { + DirectWriteResult::Overflow(required) + } +} + +/// Copy a fully-assembled wire response into `out`, or report the +/// exact required size. +fn write_wire_into(out: &mut [u8], wire: &[u8]) -> DirectWriteResult { + if wire.len() <= out.len() { + out[..wire.len()].copy_from_slice(wire); + DirectWriteResult::Complete(wire.len()) + } else { + DirectWriteResult::Overflow(wire.len()) + } +} diff --git a/crates/vespera_inprocess/src/envelope.rs b/crates/vespera_inprocess/src/envelope.rs new file mode 100644 index 00000000..c571361c --- /dev/null +++ b/crates/vespera_inprocess/src/envelope.rs @@ -0,0 +1,80 @@ +//! Public request/response envelope types for the direct (text) API. + +use std::borrow::Cow; +use std::collections::{BTreeMap, HashMap}; + +use serde::{Deserialize, Serialize}; + +// ── Envelope Types ─────────────────────────────────────────────────── + +/// Inbound request envelope (direct-API path). +#[derive(Debug, Default, Clone, Deserialize)] +pub struct RequestEnvelope { + pub method: String, + pub path: String, + #[serde(default)] + pub query: String, + #[serde(default)] + pub headers: HashMap, + #[serde(default)] + pub body: String, +} + +/// Response header value — single string or multiple values. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum HeaderValue { + Single(String), + Multi(Vec), +} + +/// Metadata included in every response envelope. +/// +/// `version` is a [`Cow`] so the engine can attach its own version +/// (`CARGO_PKG_VERSION`, a `&'static str`) without a per-response heap +/// allocation, while callers constructing envelopes manually can still +/// supply owned strings. +#[derive(Debug, Clone, Serialize)] +pub struct ResponseMetadata { + pub version: Cow<'static, str>, +} + +impl ResponseMetadata { + /// Metadata carrying this crate's compile-time version — zero + /// allocation (borrows the `'static` version string). + #[must_use] + pub const fn current() -> Self { + Self { + version: Cow::Borrowed(env!("CARGO_PKG_VERSION")), + } + } +} + +/// Outbound response envelope. +/// +/// `body` carries the response body decoded as UTF-8 text. For +/// binary responses that are not valid UTF-8, `body` will be the +/// empty string — callers that need raw bytes must use the binary +/// wire path ([`dispatch_from_bytes`]) instead of [`dispatch_typed`] +/// / [`dispatch_owned`]. +#[derive(Debug, Serialize)] +pub struct ResponseEnvelope { + pub status: u16, + pub headers: BTreeMap, + /// UTF-8 text body. Empty when the upstream response body is not + /// valid UTF-8 (binary responses). Use the binary wire path for + /// faithful byte round-trips. + pub body: String, + pub metadata: ResponseMetadata, +} + +/// Build an error [`ResponseEnvelope`] with status 500. +#[must_use] +pub fn error_envelope(message: &str) -> ResponseEnvelope { + ResponseEnvelope { + status: 500, + headers: BTreeMap::new(), + body: message.to_owned(), + metadata: ResponseMetadata::current(), + } +} diff --git a/crates/vespera_inprocess/src/internal.rs b/crates/vespera_inprocess/src/internal.rs new file mode 100644 index 00000000..6048db86 --- /dev/null +++ b/crates/vespera_inprocess/src/internal.rs @@ -0,0 +1,368 @@ +//! Internal dispatch plumbing shared by every public entry point: +//! request building, router oneshot driving, and response collection. + +use std::collections::BTreeMap; +use std::collections::btree_map::Entry; + +use axum::body::Body; +use bytes::Bytes; +use http::{Method, Request}; +use http_body_util::BodyExt; +use tower::ServiceExt; + +use crate::Router; +use crate::envelope::{HeaderValue, ResponseEnvelope, ResponseMetadata}; + +// ── Internal Helpers ───────────────────────────────────────────────── + +/// Raw response parts on the wire path. Headers stay as the owned +/// [`http::HeaderMap`] taken from `Response::into_parts` — zero +/// per-header allocation; conversion to the public +/// `BTreeMap` shape happens only on the text +/// envelope path ([`to_response_envelope_text`]). +pub type ResponseParts = (u16, http::HeaderMap, Bytes, ResponseMetadata); + +/// Drive a [`Router`] with the supplied envelope fields and return +/// raw response parts. +/// +/// Returns `Err((status, msg))` only for pre-dispatch errors +/// (currently only "invalid HTTP method" → 405). Router/handler +/// errors cannot occur because axum routers are +/// `Service<_, Error = Infallible>`. +pub async fn dispatch_parts<'h>( + router: Router, + method_str: &str, + path: &str, + query: &str, + headers: impl Iterator, + body_bytes: Bytes, +) -> Result { + let Ok(http_method) = method_str.parse::() else { + return Err(( + 405, + format!("Method Not Allowed: '{method_str}' is not a valid HTTP method"), + )); + }; + + let mut builder = request_builder(http_method, path, query); + // Case-insensitive Content-Type detection (RFC 7230 §3.2), + // tracked inside the single header pass. + let mut has_content_type = false; + for (name, value) in headers { + has_content_type = has_content_type || name.eq_ignore_ascii_case("content-type"); + builder = builder.header(name, value); + } + if !body_bytes.is_empty() && !has_content_type { + builder = builder.header("content-type", "application/json"); + } + + // A malformed wire `path` (e.g. a raw space → not a valid + // `http::Uri`) or an invalid header name/value surfaces here as a + // builder error; convert it to a 400 so the contract "every failure + // returns a wire response" holds instead of panicking. + let request = match builder.body(Body::from(body_bytes)) { + Ok(req) => req, + Err(e) => return Err((400, format!("invalid request: {e}"))), + }; + + let response = router + .oneshot(request) + .await + .expect("router error is Infallible"); + + Ok(collect_response_parts(response).await) +} + +/// Start a request builder with method + URI. When `query` is empty +/// the borrowed `path` feeds `Uri` parsing directly — no intermediate +/// `String`; otherwise a single exact-capacity join is allocated. +fn request_builder(method: Method, path: &str, query: &str) -> http::request::Builder { + let builder = Request::builder().method(method); + if query.is_empty() { + builder.uri(path) + } else { + let mut uri = String::with_capacity(path.len() + 1 + query.len()); + uri.push_str(path); + uri.push('?'); + uri.push_str(query); + builder.uri(uri) + } +} + +/// Drive a [`Router`] and stream response body chunks through +/// `on_chunk`, returning the status/headers/metadata once the body +/// stream finishes. +/// +/// Same pre-dispatch error semantics as [`dispatch_parts`] (invalid +/// HTTP method → `Err((405, ...))`). Body stream errors are silently +/// ended (the consumer sees a truncated response) because they +/// indicate the upstream handler aborted; the headers/status that +/// were already collected remain accurate. +pub async fn dispatch_response_streaming<'h, F>( + router: Router, + method_str: &str, + path: &str, + query: &str, + headers: impl Iterator, + body_bytes: Bytes, + on_chunk: &mut F, +) -> Result<(u16, http::HeaderMap, ResponseMetadata), (u16, String)> +where + F: FnMut(&[u8]), +{ + let Ok(http_method) = method_str.parse::() else { + return Err(( + 405, + format!("Method Not Allowed: '{method_str}' is not a valid HTTP method"), + )); + }; + + let mut builder = request_builder(http_method, path, query); + let mut has_content_type = false; + for (name, value) in headers { + has_content_type = has_content_type || name.eq_ignore_ascii_case("content-type"); + builder = builder.header(name, value); + } + if !body_bytes.is_empty() && !has_content_type { + builder = builder.header("content-type", "application/json"); + } + + // A malformed wire `path` (e.g. a raw space → not a valid + // `http::Uri`) or an invalid header name/value surfaces here as a + // builder error; convert it to a 400 so the contract "every failure + // returns a wire response" holds instead of panicking. + let request = match builder.body(Body::from(body_bytes)) { + Ok(req) => req, + Err(e) => return Err((400, format!("invalid request: {e}"))), + }; + + let response = router + .oneshot(request) + .await + .expect("router error is Infallible"); + + let (parts, mut body) = response.into_parts(); + + // Stream body chunks: pull frames one at a time and surface only + // data frames (trailers are dropped — wire format does not carry + // them). Frame errors or end-of-stream both terminate cleanly. + while let Some(Ok(frame)) = body.frame().await { + if let Some(data) = frame.data_ref() + && !data.is_empty() + { + on_chunk(data.as_ref()); + } + } + + Ok(( + parts.status.as_u16(), + parts.headers, + ResponseMetadata::current(), + )) +} + +/// Collapse an [`http::HeaderMap`] into the wire's name → value map. +/// Headers with repeated names (e.g. `set-cookie`) are preserved as +/// [`HeaderValue::Multi`] so their semantics survive the conversion. +fn collect_header_map(headers: &http::HeaderMap) -> BTreeMap { + let mut resp_headers: BTreeMap = BTreeMap::new(); + for (name, value) in headers { + let val_str = value.to_str().unwrap_or("").to_owned(); + match resp_headers.entry(name.as_str().to_owned()) { + Entry::Vacant(e) => { + e.insert(HeaderValue::Single(val_str)); + } + Entry::Occupied(mut e) => { + let slot = e.get_mut(); + let new_slot = match std::mem::replace(slot, HeaderValue::Single(String::new())) { + HeaderValue::Single(prev) => HeaderValue::Multi(vec![prev, val_str]), + HeaderValue::Multi(mut v) => { + v.push(val_str); + HeaderValue::Multi(v) + } + }; + *slot = new_slot; + } + } + } + resp_headers +} + +/// Collect status, headers, body bytes, and metadata from an axum +/// response. Headers with repeated names are collapsed into +/// [`HeaderValue::Multi`] so semantics (e.g. `set-cookie`) are +/// preserved. +async fn collect_response_parts(response: axum::response::Response) -> ResponseParts { + let (parts, body) = response.into_parts(); + + let body_bytes = body + .collect() + .await + .map(http_body_util::Collected::to_bytes) + .unwrap_or_default(); + + ( + parts.status.as_u16(), + parts.headers, + body_bytes, + ResponseMetadata::current(), + ) +} + +/// Adapter: response parts → text envelope. Non-UTF-8 bodies become +/// the empty string. The owned-`String` header conversion happens +/// only here — the wire path serializes straight from the +/// [`http::HeaderMap`]. +pub fn to_response_envelope_text(parts: ResponseParts) -> ResponseEnvelope { + let (status, headers, body_bytes, metadata) = parts; + // `Vec::from(Bytes)` reuses the underlying buffer when the `Bytes` + // is uniquely owned (the common case for a collected response body), + // copying only for a shared/static slice — unlike `to_vec()`, which + // always allocates and copies. Semantics preserved: a non-UTF-8 + // body still yields the empty string. + let body = String::from_utf8(Vec::from(body_bytes)).unwrap_or_default(); + ResponseEnvelope { + status, + headers: collect_header_map(&headers), + body, + metadata, + } +} + +/// Dispatch a request and split the response into +/// `(status, headers, metadata, body)` — exposing `axum::body::Body` +/// so callers can stream it themselves (vs. collecting it eagerly). +/// +/// Used by the `*_with_header` streaming variants which need to emit +/// the wire-format header **before** body bytes start flowing. +/// +/// `default_json_content_type` adds `content-type: application/json` +/// to the outgoing request (mirroring [`dispatch_parts`]'s defaulting) +/// — only [`dispatch_into_async`] sets it, because streaming callers +/// hand this function an opaque [`Body`] whose emptiness is +/// unknowable up front. +pub async fn dispatch_and_split<'h>( + router: Router, + method_str: &str, + path: &str, + query: &str, + headers: impl Iterator, + body: Body, + default_json_content_type: bool, +) -> Result<(u16, http::HeaderMap, ResponseMetadata, Body), (u16, String)> { + let Ok(http_method) = method_str.parse::() else { + return Err(( + 405, + format!("Method Not Allowed: '{method_str}' is not a valid HTTP method"), + )); + }; + + let mut builder = request_builder(http_method, path, query); + for (name, value) in headers { + builder = builder.header(name, value); + } + if default_json_content_type { + builder = builder.header("content-type", "application/json"); + } + + // Same contract as dispatch_parts: a malformed path/header must + // surface as a 400 wire response, not a panic. + let request = match builder.body(body) { + Ok(req) => req, + Err(e) => return Err((400, format!("invalid request: {e}"))), + }; + + let response = router + .oneshot(request) + .await + .expect("router error is Infallible"); + + let (parts, body) = response.into_parts(); + Ok(( + parts.status.as_u16(), + parts.headers, + ResponseMetadata::current(), + body, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn block_on(fut: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build current-thread runtime") + .block_on(fut) + } + + /// A wire `path` that cannot be parsed into an [`http::Uri`] (a raw + /// space is illegal) must surface as an `Err((4xx, _))` the caller + /// turns into a wire response — never a panic. Guards the + /// "all failure modes return a valid wire response" contract for + /// every `request_builder` call site. + #[test] + fn malformed_path_returns_error_not_panic() { + let result = block_on(async { + dispatch_parts( + crate::Router::new(), + "GET", + "bad path with spaces", + "", + std::iter::empty(), + Bytes::new(), + ) + .await + }); + match result { + Err((status, _)) => assert!( + (400..500).contains(&status), + "expected 4xx for malformed path, got {status}" + ), + Ok(_) => panic!("malformed path should not produce a successful dispatch"), + } + } + + #[test] + fn malformed_path_streaming_returns_error_not_panic() { + let result = block_on(async { + let mut sink = |_: &[u8]| {}; + dispatch_response_streaming( + crate::Router::new(), + "GET", + "bad path with spaces", + "", + std::iter::empty(), + Bytes::new(), + &mut sink, + ) + .await + }); + assert!( + result.is_err(), + "streaming dispatch must reject malformed path" + ); + } + + #[test] + fn malformed_path_split_returns_error_not_panic() { + let result = block_on(async { + dispatch_and_split( + crate::Router::new(), + "GET", + "bad path with spaces", + "", + std::iter::empty(), + Body::empty(), + false, + ) + .await + }); + assert!( + result.is_err(), + "dispatch_and_split must reject malformed path" + ); + } +} diff --git a/crates/vespera_inprocess/src/lib.rs b/crates/vespera_inprocess/src/lib.rs index c47b6c12..7fa8fa40 100644 --- a/crates/vespera_inprocess/src/lib.rs +++ b/crates/vespera_inprocess/src/lib.rs @@ -13,6 +13,18 @@ //! empty string. Callers that need raw bytes must use the //! binary wire API below. //! +//! This API is intended for **in-process Rust embedding** where a +//! typed envelope is convenient. It is not the throughput-oriented +//! path: the response headers are materialised into an owned +//! `BTreeMap` and the body is decoded to a +//! `String`. **FFI / high-throughput callers should prefer the +//! binary wire API** ([`dispatch_from_bytes`] / [`dispatch_into`]), +//! which borrows the wire header, serialises response headers +//! straight from the `http::HeaderMap`, and carries the body as raw +//! bytes (no UTF-8 round-trip). Within the direct API itself, +//! prefer [`dispatch_owned`] over [`dispatch`] / [`dispatch_typed`] +//! to avoid cloning the request envelope. +//! //! 2. **Binary wire API** — [`dispatch_from_bytes`] is the //! zero-overhead FFI entry point. Wire format (request and //! response use the same layout): @@ -56,1200 +68,32 @@ //! [`Router::clone`], which is cheap because axum's router is //! internally `Arc`-shared. -use std::collections::HashMap; -use std::collections::hash_map::Entry; -use std::convert::Infallible; -use std::pin::Pin; -use std::sync::{LazyLock, RwLock}; -use std::task::{Context, Poll}; - -use axum::body::Body; -use bytes::Bytes; -use http::{Method, Request}; -use http_body::{Body as HttpBody, Frame}; -use http_body_util::BodyExt; -use serde::{Deserialize, Serialize}; -use tower::ServiceExt; +mod config; +mod dispatch; +mod envelope; +mod internal; +mod registry; +mod streaming; +mod wire; /// Re-export `axum::Router` so consumers don't need a direct axum dependency. pub use axum::Router; - -/// Wire format protocol version. The JSON header's `v` field MUST -/// equal this for requests; responses always emit this value. -const WIRE_VERSION: u8 = 1; - -/// Canonical name of the default app — used when the wire header -/// omits `"app"` or sets it to an empty string, and when callers use -/// the BC [`register_app`] entry point. -pub const DEFAULT_APP_NAME: &str = "_default"; - -/// Maximum allowed length of an app name (after trimming). Sized so -/// names fit comfortably in URL path segments and log lines. -const MAX_APP_NAME_LEN: usize = 64; - -// ── Envelope Types ─────────────────────────────────────────────────── - -/// Inbound request envelope (direct-API path). -#[derive(Debug, Default, Clone, Deserialize)] -pub struct RequestEnvelope { - pub method: String, - pub path: String, - #[serde(default)] - pub query: String, - #[serde(default)] - pub headers: HashMap, - #[serde(default)] - pub body: String, -} - -/// Response header value — single string or multiple values. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -#[serde(untagged)] -pub enum HeaderValue { - Single(String), - Multi(Vec), -} - -/// Metadata included in every response envelope. -#[derive(Debug, Clone, Serialize)] -pub struct ResponseMetadata { - pub version: String, -} - -/// Outbound response envelope. -/// -/// `body` carries the response body decoded as UTF-8 text. For -/// binary responses that are not valid UTF-8, `body` will be the -/// empty string — callers that need raw bytes must use the binary -/// wire path ([`dispatch_from_bytes`]) instead of [`dispatch_typed`] -/// / [`dispatch_owned`]. -#[derive(Debug, Serialize)] -pub struct ResponseEnvelope { - pub status: u16, - pub headers: HashMap, - /// UTF-8 text body. Empty when the upstream response body is not - /// valid UTF-8 (binary responses). Use the binary wire path for - /// faithful byte round-trips. - pub body: String, - pub metadata: ResponseMetadata, -} - -// ── Wire Format Types (internal) ───────────────────────────────────── - -#[derive(Debug, Deserialize)] -struct WireRequestHeader { - /// Wire protocol version; clients MUST send 1. - #[serde(default)] - v: u8, - method: String, - path: String, - #[serde(default)] - query: String, - #[serde(default)] - headers: HashMap, - /// Optional name of the target app for multi-app routing. When - /// omitted (or empty), the request is dispatched to the default - /// app registered via [`register_app`]. Use [`register_app_named`] - /// to register additional named apps. - #[serde(default)] - app: Option, -} - -#[derive(Debug, Serialize)] -struct WireResponseHeader<'a> { - v: u8, - status: u16, - headers: &'a HashMap, - metadata: &'a ResponseMetadata, - /// Validation errors hoisted from a 422 JSON body so Java decoders - /// can read them with a single header parse. `None` for any other - /// status; the original body is preserved verbatim regardless. - #[serde(skip_serializing_if = "Option::is_none")] - validation_errors: Option>, -} - -/// One entry in the wire header's `validation_errors` array. Fields -/// are best-effort: missing values in the source body become `None`. -#[derive(Debug, Serialize)] -struct ValidationErrorItem { - path: String, - #[serde(skip_serializing_if = "Option::is_none")] - code: Option, - #[serde(skip_serializing_if = "Option::is_none")] - message: Option, -} - -// ── Dispatch (direct API — backward compatible) ────────────────────── - -/// Dispatch a [`RequestEnvelope`] through an axum [`Router`] and -/// return the serialised [`ResponseEnvelope`] JSON. -/// -/// This borrows the envelope and clones its owned fields before -/// passing them to the hot path. Callers that already own a -/// [`RequestEnvelope`] should prefer [`dispatch_owned`] to skip the -/// clone. -pub async fn dispatch(router: Router, envelope: &RequestEnvelope) -> String { - let result = dispatch_owned(router, envelope.clone()).await; - serde_json::to_string(&result).expect("ResponseEnvelope serialization is infallible") -} - -/// Typed dispatch — returns a [`ResponseEnvelope`] directly. -/// -/// See [`dispatch`] for the clone trade-off; prefer [`dispatch_owned`] -/// when the envelope is already owned. -pub async fn dispatch_typed(router: Router, envelope: &RequestEnvelope) -> ResponseEnvelope { - dispatch_owned(router, envelope.clone()).await -} - -/// Dispatch an owned [`RequestEnvelope`] — moves the envelope into -/// the HTTP request so the body, path, and headers are never cloned. -/// -/// This is the hot path used by callers (e.g. custom FFI transports) -/// that already own a freshly built envelope. -pub async fn dispatch_owned(router: Router, envelope: RequestEnvelope) -> ResponseEnvelope { - let parts = match dispatch_parts( - router, - &envelope.method, - envelope.path, - envelope.query, - envelope.headers, - envelope.body.into_bytes(), - ) - .await - { - Ok(parts) => parts, - Err((status, msg)) => { - return ResponseEnvelope { - status, - headers: HashMap::new(), - body: msg, - metadata: ResponseMetadata { - version: env!("CARGO_PKG_VERSION").to_owned(), - }, - }; - } - }; - to_response_envelope_text(parts) -} - -/// Build an error [`ResponseEnvelope`] with status 500. -#[must_use] -pub fn error_envelope(message: &str) -> ResponseEnvelope { - ResponseEnvelope { - status: 500, - headers: HashMap::new(), - body: message.to_owned(), - metadata: ResponseMetadata { - version: env!("CARGO_PKG_VERSION").to_owned(), - }, - } -} - -// ── App Factory (shared FFI pattern) ───────────────────────────────── - -/// Per-name router cache. Indexed by app name; the default app uses -/// [`DEFAULT_APP_NAME`] (`"_default"`). -/// -/// Uses [`RwLock`] (not [`OnceLock`]) so multiple named apps can be -/// registered after init time, while keeping dispatch reads -/// contention-free. The map is read on every dispatch and written -/// only during `register_app*` calls (typically at process startup). -/// -/// Lock poisoning recovery: every read path uses -/// `unwrap_or_else(|e| e.into_inner())` so a panic in a producer -/// thread does not lock out the dispatch hot path. Factory closures -/// are also invoked **outside** the write lock so a factory panic -/// cannot poison the map. -static APP_ROUTERS: LazyLock>> = - LazyLock::new(|| RwLock::new(HashMap::new())); - -/// Validate an app name for registration / lookup. -/// -/// Constraints: -/// - non-empty after trimming whitespace -/// - at most [`MAX_APP_NAME_LEN`] bytes -/// - ASCII alphanumeric, `_`, or `-` only -/// -/// Returns the trimmed name on success. -fn validate_app_name(name: &str) -> Result<&str, String> { - let trimmed = name.trim(); - if trimmed.is_empty() { - return Err("app name must not be empty".to_owned()); - } - if trimmed.len() > MAX_APP_NAME_LEN { - return Err(format!( - "app name too long: {} chars (max {MAX_APP_NAME_LEN})", - trimmed.len() - )); - } - if !trimmed - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') - { - return Err(format!( - "app name '{trimmed}' contains invalid characters (allowed: alphanumeric, '_', '-')" - )); - } - Ok(trimmed) -} - -/// Register the **default** global router factory. -/// -/// Equivalent to `register_app_named(DEFAULT_APP_NAME, factory)`. -/// Wire requests without an `"app"` header (or with `"app": ""`) are -/// routed here. -/// -/// Any FFI boundary (JNI, C, WASM) calls this once at init time, then -/// uses [`dispatch_from_bytes`] on each request. -/// -/// # Second-call semantics -/// -/// Calling `register_app` more than once is a **no-op** — the first -/// registration wins, the new factory closure is NOT invoked. Friendly -/// for environments that legitimately load the cdylib twice (hot-reloading -/// JVM hosts, plugin systems). -pub fn register_app(factory: F) -where - F: Fn() -> Router + Send + Sync + 'static, -{ - register_app_named(DEFAULT_APP_NAME, factory); -} - -/// Register a **named** global router factory for multi-app routing. -/// -/// Wire requests carrying `"app": ""` in their header are -/// dispatched to this router. Multiple named apps can coexist in -/// the same process; register each once at init time. -/// -/// # First-wins per name -/// -/// Calling this more than once with the same `name` is a no-op — the -/// first registration wins. Registering different names is the -/// supported multi-app pattern. -/// -/// # Panic safety -/// -/// The `factory` closure is invoked **outside** the internal -/// `RwLock`'s write guard. A panic in `factory` cannot poison the -/// map; the registration is simply discarded and the slot remains -/// available for retry. -/// -/// # Invalid names -/// -/// Names that fail [`validate_app_name`] (empty, > 64 bytes, or -/// containing characters outside `[A-Za-z0-9_-]`) are silently -/// discarded — registration is a no-op. Dispatch with a matching -/// invalid name will return a `400` wire response. -pub fn register_app_named(name: &str, factory: F) -where - F: Fn() -> Router + Send + Sync + 'static, -{ - let name = match validate_app_name(name) { - Ok(n) => n.to_owned(), - Err(_) => return, - }; - // Fast path: existence check under a read lock. - { - let map = APP_ROUTERS - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if map.contains_key(&name) { - return; - } - } - // Build the router OUTSIDE the write lock so a panicking factory - // cannot poison the map. - let router = factory(); - let mut map = APP_ROUTERS - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner); - // Double-check: another thread may have inserted between our read - // and write. First-wins still holds — use Entry to avoid the - // map.contains_key + map.insert double lookup. - map.entry(name).or_insert(router); -} - -/// Resolve a [`Router`] for a wire request, applying default-app -/// fallback and name validation. Returns the cloned router (cheap — -/// axum's router is `Arc`-backed) on success, or a wire error response -/// (`400` for invalid name, `404` for unregistered name) on failure. -fn resolve_app_router(header: &WireRequestHeader) -> Result> { - let raw = header - .app - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - .unwrap_or(DEFAULT_APP_NAME); - let name = match validate_app_name(raw) { - Ok(n) => n, - Err(msg) => return Err(error_wire(400, &format!("invalid app name: {msg}"))), - }; - let map = APP_ROUTERS - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner); - map.get(name).cloned().ok_or_else(|| { - error_wire( - 404, - &format!( - "no app registered with name '{name}' — \ - use register_app() for the default app or \ - register_app_named(name, factory) for additional apps" - ), - ) - }) -} - -// ── Binary Wire API ────────────────────────────────────────────────── - -/// Dispatch a wire-format request through the registered app and -/// return a wire-format response. -/// -/// Wire format: -/// ```text -/// bytes 0..4 : u32 BE = header_json byte length N -/// bytes 4..4+N : UTF-8 JSON -/// (request) { "v":1, "method", "path", -/// "query"?, "headers"? } -/// (response) { "v":1, "status", "headers", -/// "metadata" } -/// bytes 4+N..end : raw body bytes (UTF-8 text or binary — -/// no encoding applied) -/// ``` -/// -/// All failure modes return a valid wire-format response (length- -/// prefixed) so the caller's decoder never has to special-case -/// errors. Specifically: -/// -/// * input shorter than 4 bytes → 400 with explanatory body -/// * `header_len` exceeds input → 400 -/// * header JSON parse failure → 400 -/// * wire version mismatch → 400 -/// * unknown HTTP method → 405 -/// * no app registered → 500 -/// * router/handler errors → surfaced verbatim as response wire -pub fn dispatch_from_bytes(input: Vec, runtime: &tokio::runtime::Runtime) -> Vec { - runtime.block_on(dispatch_from_bytes_async(input)) -} - -/// **Streaming** sibling of [`dispatch_from_bytes_async`]. -/// -/// Drives the dispatch end-to-end like the non-streaming variant but -/// emits the response body **chunk-by-chunk via `on_chunk`** instead -/// of materialising it in a single `Vec`. Returns the wire-format -/// header bytes only (`[u32 BE header_len | header JSON]`) — the body -/// is delivered through the callback while the dispatch is in flight, -/// so a 1 GiB response is never resident in memory. -/// -/// `on_chunk` is invoked one or more times in arrival order; the -/// borrowed slice is valid only for the duration of each call and the -/// callback should treat it as ephemeral (e.g. write it to an -/// `OutputStream`, accumulate it on disk, …). -/// -/// Failure modes are identical to [`dispatch_from_bytes_async`] — -/// returns a valid wire-format error response (header + body) when -/// the wire input is malformed, the version is wrong, no app is -/// registered, or the handler reports a pre-dispatch error. In the -/// error path the body is included inside the returned bytes (not -/// streamed via `on_chunk`) because the error message is small. -/// -/// `on_chunk` is NOT called if the response body is empty. -pub async fn dispatch_streaming_async(input: Vec, mut on_chunk: F) -> Vec -where - F: FnMut(&[u8]), -{ - let (header, body_bytes) = match parse_wire_request(input) { - Ok(parts) => parts, - Err(msg) => return error_wire(400, &msg), - }; - if header.v != WIRE_VERSION { - return error_wire( - 400, - &format!( - "unsupported wire version: got {}, expected {WIRE_VERSION}", - header.v - ), - ); - } - let router = match resolve_app_router(&header) { - Ok(r) => r, - Err(wire) => return wire, - }; - let (status, headers, metadata) = match dispatch_response_streaming( - router, - &header.method, - header.path, - header.query, - header.headers, - body_bytes, - &mut on_chunk, - ) - .await - { - Ok(parts) => parts, - Err((status, msg)) => return error_wire(status, &msg), - }; - // Emit header-only wire bytes; body was streamed via on_chunk. - let header_view = WireResponseHeader { - v: WIRE_VERSION, - status, - headers: &headers, - metadata: &metadata, - // Streaming path does not hoist 422 validation errors — - // hoisting requires materialising the full body, which is - // antithetical to the streaming contract. Callers needing - // validation hoisting should use dispatch_from_bytes_async. - validation_errors: None, - }; - let header_json = - serde_json::to_vec(&header_view).expect("WireResponseHeader serialization is infallible"); - let header_len = - u32::try_from(header_json.len()).expect("response header JSON exceeds u32::MAX bytes"); - let mut out = Vec::with_capacity(4 + header_json.len()); - out.extend_from_slice(&header_len.to_be_bytes()); - out.extend_from_slice(&header_json); - out -} - -/// Async sibling of [`dispatch_from_bytes`]. Use this when the caller -/// is already inside a Tokio runtime (e.g. an axum handler embedding -/// another vespera router, or a tokio-spawned task in the JNI bridge's -/// async dispatch path). -/// -/// All failure modes return a valid wire-format response (same -/// guarantees as [`dispatch_from_bytes`]), including `500` when no app -/// is registered. -pub async fn dispatch_from_bytes_async(input: Vec) -> Vec { - // Wire-level checks first: malformed input must report parse - // errors regardless of whether an app is registered. - let (header, body_bytes) = match parse_wire_request(input) { - Ok(parts) => parts, - Err(msg) => return error_wire(400, &msg), - }; - if header.v != WIRE_VERSION { - return error_wire( - 400, - &format!( - "unsupported wire version: got {}, expected {WIRE_VERSION}", - header.v - ), - ); - } - let router = match resolve_app_router(&header) { - Ok(r) => r, - Err(wire) => return wire, - }; - let parts = match dispatch_parts( - router, - &header.method, - header.path, - header.query, - header.headers, - body_bytes, - ) - .await - { - Ok(parts) => parts, - Err((status, msg)) => return error_wire(status, &msg), - }; - to_wire_bytes(parts) -} - -/// Build a wire-format error response with a plain-text body. -/// -/// Used by [`dispatch_from_bytes`] for malformed input and by the -/// JNI bridge for panic fallback. The response always carries -/// `content-type: text/plain; charset=utf-8`. -#[must_use] -pub fn error_wire(status: u16, msg: &str) -> Vec { - let mut headers = HashMap::new(); - headers.insert( - "content-type".to_owned(), - HeaderValue::Single("text/plain; charset=utf-8".to_owned()), - ); - let metadata = ResponseMetadata { - version: env!("CARGO_PKG_VERSION").to_owned(), - }; - let parts = ( - status, - headers, - Bytes::from(msg.as_bytes().to_vec()), - metadata, - ); - to_wire_bytes(parts) -} - -// ── Internal Helpers ───────────────────────────────────────────────── - -type ResponseParts = (u16, HashMap, Bytes, ResponseMetadata); - -/// Drive a [`Router`] with the supplied envelope fields and return -/// raw response parts. -/// -/// Returns `Err((status, msg))` only for pre-dispatch errors -/// (currently only "invalid HTTP method" → 405). Router/handler -/// errors cannot occur because axum routers are -/// `Service<_, Error = Infallible>`. -async fn dispatch_parts( - router: Router, - method_str: &str, - path: String, - query: String, - headers: HashMap, - body_bytes: Vec, -) -> Result { - let Ok(http_method) = method_str.parse::() else { - return Err(( - 405, - format!("Method Not Allowed: '{method_str}' is not a valid HTTP method"), - )); - }; - - let uri = if query.is_empty() { - path - } else { - format!("{path}?{query}") - }; - - // Case-insensitive Content-Type detection (RFC 7230 §3.2). - let has_content_type = headers - .keys() - .any(|k| k.eq_ignore_ascii_case("content-type")); - - let mut builder = Request::builder().method(http_method).uri(&uri); - for (name, value) in &headers { - builder = builder.header(name.as_str(), value.as_str()); - } - if !body_bytes.is_empty() && !has_content_type { - builder = builder.header("content-type", "application/json"); - } - - let request = builder - .body(Body::from(body_bytes)) - .expect("request construction should not fail with valid URI"); - - let response = router - .oneshot(request) - .await - .expect("router error is Infallible"); - - Ok(collect_response_parts(response).await) -} - -/// Drive a [`Router`] and stream response body chunks through -/// `on_chunk`, returning the status/headers/metadata once the body -/// stream finishes. -/// -/// Same pre-dispatch error semantics as [`dispatch_parts`] (invalid -/// HTTP method → `Err((405, ...))`). Body stream errors are silently -/// ended (the consumer sees a truncated response) because they -/// indicate the upstream handler aborted; the headers/status that -/// were already collected remain accurate. -async fn dispatch_response_streaming( - router: Router, - method_str: &str, - path: String, - query: String, - headers: HashMap, - body_bytes: Vec, - on_chunk: &mut F, -) -> Result<(u16, HashMap, ResponseMetadata), (u16, String)> -where - F: FnMut(&[u8]), -{ - let Ok(http_method) = method_str.parse::() else { - return Err(( - 405, - format!("Method Not Allowed: '{method_str}' is not a valid HTTP method"), - )); - }; - - let uri = if query.is_empty() { - path - } else { - format!("{path}?{query}") - }; - - let has_content_type = headers - .keys() - .any(|k| k.eq_ignore_ascii_case("content-type")); - - let mut builder = Request::builder().method(http_method).uri(&uri); - for (name, value) in &headers { - builder = builder.header(name.as_str(), value.as_str()); - } - if !body_bytes.is_empty() && !has_content_type { - builder = builder.header("content-type", "application/json"); - } - - let request = builder - .body(Body::from(body_bytes)) - .expect("request construction should not fail with valid URI"); - - let response = router - .oneshot(request) - .await - .expect("router error is Infallible"); - - let version = env!("CARGO_PKG_VERSION").to_owned(); - let status = response.status().as_u16(); - - let mut resp_headers: HashMap = - HashMap::with_capacity(response.headers().len()); - for (name, value) in response.headers() { - let val_str = value.to_str().unwrap_or("").to_owned(); - match resp_headers.entry(name.as_str().to_owned()) { - Entry::Vacant(e) => { - e.insert(HeaderValue::Single(val_str)); - } - Entry::Occupied(mut e) => { - let slot = e.get_mut(); - let new_slot = match std::mem::replace(slot, HeaderValue::Single(String::new())) { - HeaderValue::Single(prev) => HeaderValue::Multi(vec![prev, val_str]), - HeaderValue::Multi(mut v) => { - v.push(val_str); - HeaderValue::Multi(v) - } - }; - *slot = new_slot; - } - } - } - - // Stream body chunks: pull frames one at a time and surface only - // data frames (trailers are dropped — wire format does not carry - // them). Frame errors or end-of-stream both terminate cleanly. - let mut body = response.into_body(); - while let Some(Ok(frame)) = body.frame().await { - if let Some(data) = frame.data_ref() - && !data.is_empty() - { - on_chunk(data.as_ref()); - } - } - - Ok((status, resp_headers, ResponseMetadata { version })) -} - -/// Collect status, headers, body bytes, and metadata from an axum -/// response. Headers with repeated names are collapsed into -/// [`HeaderValue::Multi`] so semantics (e.g. `set-cookie`) are -/// preserved. -async fn collect_response_parts(response: axum::response::Response) -> ResponseParts { - let version = env!("CARGO_PKG_VERSION").to_owned(); - let status = response.status().as_u16(); - - let mut resp_headers: HashMap = - HashMap::with_capacity(response.headers().len()); - for (name, value) in response.headers() { - let val_str = value.to_str().unwrap_or("").to_owned(); - match resp_headers.entry(name.as_str().to_owned()) { - Entry::Vacant(e) => { - e.insert(HeaderValue::Single(val_str)); - } - Entry::Occupied(mut e) => { - let slot = e.get_mut(); - let new_slot = match std::mem::replace(slot, HeaderValue::Single(String::new())) { - HeaderValue::Single(prev) => HeaderValue::Multi(vec![prev, val_str]), - HeaderValue::Multi(mut v) => { - v.push(val_str); - HeaderValue::Multi(v) - } - }; - *slot = new_slot; - } - } - } - - let body_bytes = response - .into_body() - .collect() - .await - .map(http_body_util::Collected::to_bytes) - .unwrap_or_default(); - - ( - status, - resp_headers, - body_bytes, - ResponseMetadata { version }, - ) -} - -/// Adapter: response parts → text envelope. Non-UTF-8 bodies become -/// the empty string. -fn to_response_envelope_text(parts: ResponseParts) -> ResponseEnvelope { - let (status, headers, body_bytes, metadata) = parts; - let body = String::from_utf8(body_bytes.to_vec()).unwrap_or_default(); - ResponseEnvelope { - status, - headers, - body, - metadata, - } -} - -/// Adapter: response parts → wire-format bytes. Layout: -/// `[u32 BE header_len | JSON header | raw body]`. -/// -/// For `status == 422` JSON responses we **best-effort** hoist any -/// `{"errors": [...]}` payload into the wire header's -/// `validation_errors` field — Java decoders can read validation -/// failures with a single header parse, while the original body is -/// preserved verbatim for clients that still rely on it. -fn to_wire_bytes(parts: ResponseParts) -> Vec { - let (status, headers, body_bytes, metadata) = parts; - let validation_errors = if status == 422 { - try_hoist_validation_errors(&headers, &body_bytes) - } else { - None - }; - let header = WireResponseHeader { - v: WIRE_VERSION, - status, - headers: &headers, - metadata: &metadata, - validation_errors, - }; - let header_json = - serde_json::to_vec(&header).expect("WireResponseHeader serialization is infallible"); - let header_len = - u32::try_from(header_json.len()).expect("response header JSON exceeds u32::MAX bytes"); - let mut out = Vec::with_capacity(4 + header_json.len() + body_bytes.len()); - out.extend_from_slice(&header_len.to_be_bytes()); - out.extend_from_slice(&header_json); - out.extend_from_slice(&body_bytes); - out -} - -/// Dispatch a request and split the response into -/// `(status, headers, metadata, body)` — exposing `axum::body::Body` -/// so callers can stream it themselves (vs. collecting it eagerly). -/// -/// Used by the `*_with_header` streaming variants which need to emit -/// the wire-format header **before** body bytes start flowing. -async fn dispatch_and_split( - router: Router, - method_str: &str, - path: String, - query: String, - headers: HashMap, - body: Body, -) -> Result<(u16, HashMap, ResponseMetadata, Body), (u16, String)> { - let Ok(http_method) = method_str.parse::() else { - return Err(( - 405, - format!("Method Not Allowed: '{method_str}' is not a valid HTTP method"), - )); - }; - - let uri = if query.is_empty() { - path - } else { - format!("{path}?{query}") - }; - - let mut builder = Request::builder().method(http_method).uri(&uri); - for (name, value) in &headers { - builder = builder.header(name.as_str(), value.as_str()); - } - - let request = builder - .body(body) - .expect("request construction should not fail with valid URI"); - - let response = router - .oneshot(request) - .await - .expect("router error is Infallible"); - - let version = env!("CARGO_PKG_VERSION").to_owned(); - let status = response.status().as_u16(); - - let mut resp_headers: HashMap = - HashMap::with_capacity(response.headers().len()); - for (name, value) in response.headers() { - let val_str = value.to_str().unwrap_or("").to_owned(); - match resp_headers.entry(name.as_str().to_owned()) { - Entry::Vacant(e) => { - e.insert(HeaderValue::Single(val_str)); - } - Entry::Occupied(mut e) => { - let slot = e.get_mut(); - let new_slot = match std::mem::replace(slot, HeaderValue::Single(String::new())) { - HeaderValue::Single(prev) => HeaderValue::Multi(vec![prev, val_str]), - HeaderValue::Multi(mut v) => { - v.push(val_str); - HeaderValue::Multi(v) - } - }; - *slot = new_slot; - } - } - } - - let body = response.into_body(); - Ok((status, resp_headers, ResponseMetadata { version }, body)) -} - -/// Build wire-format header bytes (`[u32 BE header_len | JSON header]`) -/// without a body — used by the `*_with_header` callback variants. -fn build_wire_header_bytes( - status: u16, - headers: &HashMap, - metadata: &ResponseMetadata, -) -> Vec { - let view = WireResponseHeader { - v: WIRE_VERSION, - status, - headers, - metadata, - validation_errors: None, - }; - let header_json = - serde_json::to_vec(&view).expect("WireResponseHeader serialization is infallible"); - let header_len = - u32::try_from(header_json.len()).expect("response header JSON exceeds u32::MAX bytes"); - let mut out = Vec::with_capacity(4 + header_json.len()); - out.extend_from_slice(&header_len.to_be_bytes()); - out.extend_from_slice(&header_json); - out -} - -/// **Streaming dispatch with explicit header callback** — emits the -/// wire-format response header via `on_header` **before** any body -/// chunk is delivered to `on_chunk`. -/// -/// This is the variant Spring `HttpServletResponse`-based controllers -/// want: `on_header` fires while the response is still uncommitted, -/// so the controller can call `resp.setStatus(...)` / -/// `resp.setHeader(...)` from the callback. Then `on_chunk` streams -/// the body bytes one frame at a time. -/// -/// `on_header` is called **exactly once** in every code path — -/// success or error. On error (malformed wire, no app, invalid -/// method, …) the bytes passed to `on_header` are a normal -/// `error_wire(...)` response and `on_chunk` is **not** invoked. -pub async fn dispatch_streaming_with_header_async( - input: Vec, - mut on_header: H, - mut on_chunk: F, -) where - H: FnMut(&[u8]), - F: FnMut(&[u8]), -{ - let (header, body_bytes) = match parse_wire_request(input) { - Ok(parts) => parts, - Err(msg) => { - on_header(&error_wire(400, &msg)); - return; - } - }; - if header.v != WIRE_VERSION { - on_header(&error_wire( - 400, - &format!( - "unsupported wire version: got {}, expected {WIRE_VERSION}", - header.v - ), - )); - return; - } - let router = match resolve_app_router(&header) { - Ok(r) => r, - Err(wire) => { - on_header(&wire); - return; - } - }; - - let (status, headers, metadata, mut body) = match dispatch_and_split( - router, - &header.method, - header.path, - header.query, - header.headers, - Body::from(body_bytes), - ) - .await - { - Ok(parts) => parts, - Err((status, msg)) => { - on_header(&error_wire(status, &msg)); - return; - } - }; - - on_header(&build_wire_header_bytes(status, &headers, &metadata)); - - while let Some(Ok(frame)) = body.frame().await { - if let Some(data) = frame.data_ref() - && !data.is_empty() - { - on_chunk(data.as_ref()); - } - } -} - -/// Best-effort extract validation errors from a 422 JSON body. -/// -/// Returns `None` (silently) for: -/// - non-JSON content-types (anything that doesn't end in `/json` or -/// `+json`) -/// - body bytes that don't parse as JSON -/// - JSON without an `errors` array, or with an empty array -/// -/// This is intentionally lenient — a malformed 422 body must never -/// degrade to a 5xx; the original body is still surfaced verbatim. -fn try_hoist_validation_errors( - headers: &HashMap, - body_bytes: &Bytes, -) -> Option> { - let is_json = headers.iter().any(|(k, v)| { - if !k.eq_ignore_ascii_case("content-type") { - return false; - } - let s = match v { - HeaderValue::Single(s) => s.as_str(), - HeaderValue::Multi(vs) => vs.first().map_or("", String::as_str), - }; - let mime = s - .split(';') - .next() - .unwrap_or("") - .trim() - .to_ascii_lowercase(); - mime == "application/json" || mime.ends_with("+json") - }); - if !is_json { - return None; - } - let parsed: serde_json::Value = serde_json::from_slice(body_bytes).ok()?; - let errors = parsed.get("errors")?.as_array()?; - let items: Vec = errors - .iter() - .filter_map(|e| { - let path = e.get("path")?.as_str()?.to_owned(); - let code = e - .get("code") - .and_then(serde_json::Value::as_str) - .map(str::to_owned); - let message = e - .get("message") - .and_then(serde_json::Value::as_str) - .map(str::to_owned); - Some(ValidationErrorItem { - path, - code, - message, - }) - }) - .collect(); - if items.is_empty() { None } else { Some(items) } -} - -/// **Bidirectional streaming dispatch** — both request and response -/// bodies are streamed chunk-by-chunk; neither side materialises the -/// full payload in memory. -/// -/// - `input_header` is a wire-format request **without a body** -/// (just `[u32 BE header_len | JSON header]`). Send the body -/// chunks via `pull_chunk`, not embedded in this buffer. -/// - `pull_chunk` is called repeatedly to obtain request body -/// chunks. Return `Some(chunk)` for each chunk and `None` to -/// signal EOF. An empty `Some(Vec::new())` is treated as -/// "no more data right now, but keep the stream open" — rarely -/// useful; most callers should just return `None`. -/// - `on_chunk` receives response body chunks in arrival order, same -/// contract as [`dispatch_streaming_async`]. -/// -/// Returns the wire-format **header only** (`[u32 BE header_len | -/// header JSON]`) — the response body was delivered via `on_chunk`. -/// -/// `pull_chunk` runs on a Tokio blocking thread (`spawn_blocking`) -/// because the JNI implementation reads from a Java `InputStream`, -/// which is inherently blocking. Backpressure is enforced by a -/// bounded 16-slot mpsc channel: if axum reads slowly, the -/// `pull_chunk` call blocks naturally. -/// -/// Failure modes match [`dispatch_streaming_async`]: malformed -/// header / unknown version / no app / handler error → normal -/// `error_wire(...)` response (with the message inside the returned -/// bytes); neither callback is invoked in those paths. -pub async fn dispatch_bidirectional_streaming( - input_header: Vec, - pull_chunk: P, - on_chunk: F, -) -> Vec -where - P: FnMut() -> Option> + Send + 'static, - F: FnMut(&[u8]), -{ - let mut header_bytes: Vec = Vec::new(); - { - let on_header = |h: &[u8]| header_bytes.extend_from_slice(h); - bidirectional_streaming_inner(input_header, pull_chunk, on_chunk, on_header).await; - } - header_bytes -} - -/// **Bidirectional streaming with explicit header callback** — the -/// `with_header` counterpart of [`dispatch_bidirectional_streaming`]. -/// Emits the wire-format response header via `on_header` **before** -/// any response body byte reaches `on_chunk`, so Spring-style -/// `HttpServletResponse` controllers can commit status / headers -/// from the callback while the response is still uncommitted. -/// -/// `on_header` is called exactly once on every code path (success or -/// error). On any pre-dispatch / wire error the bytes passed to -/// `on_header` are a normal `error_wire(...)` response and neither -/// `pull_chunk` nor `on_chunk` is invoked beyond that point. -pub async fn dispatch_bidirectional_streaming_with_header( - input_header: Vec, - pull_chunk: P, - on_chunk: F, - on_header: H, -) where - P: FnMut() -> Option> + Send + 'static, - F: FnMut(&[u8]), - H: FnMut(&[u8]), -{ - bidirectional_streaming_inner(input_header, pull_chunk, on_chunk, on_header).await; -} - -async fn bidirectional_streaming_inner( - input_header: Vec, - pull_chunk: P, - mut on_chunk: F, - mut on_header: H, -) where - P: FnMut() -> Option> + Send + 'static, - F: FnMut(&[u8]), - H: FnMut(&[u8]), -{ - let (header, _ignored_body) = match parse_wire_request(input_header) { - Ok(parts) => parts, - Err(msg) => { - on_header(&error_wire(400, &msg)); - return; - } - }; - if header.v != WIRE_VERSION { - on_header(&error_wire( - 400, - &format!( - "unsupported wire version: got {}, expected {WIRE_VERSION}", - header.v - ), - )); - return; - } - let router = match resolve_app_router(&header) { - Ok(r) => r, - Err(wire) => { - on_header(&wire); - return; - } - }; - - // Bounded 16-slot mpsc — gives natural backpressure between the - // pull_chunk producer thread and the axum handler consumer. - let (tx, rx) = tokio::sync::mpsc::channel::(16); - - let producer_handle = tokio::task::spawn_blocking(move || { - let mut pull = pull_chunk; - // `None` from `pull()` ends the stream; an empty `Some(_)` is - // skipped (it's not EOF); a failed `blocking_send` means the - // receiver — axum's request body — was dropped because the - // handler aborted mid-stream, so we stop pulling. - while let Some(chunk) = pull() { - if chunk.is_empty() { - continue; - } - if tx.blocking_send(Bytes::from(chunk)).is_err() { - break; - } - } - // tx dropped at end of scope → axum sees end-of-stream. - }); - - let body = Body::new(ChannelBody { rx }); - let (status, headers, metadata, mut response_body) = match dispatch_and_split( - router.clone(), - &header.method, - header.path, - header.query, - header.headers, - body, - ) - .await - { - Ok(parts) => parts, - Err((status, msg)) => { - let _ = producer_handle.await; - on_header(&error_wire(status, &msg)); - return; - } - }; - - on_header(&build_wire_header_bytes(status, &headers, &metadata)); - - while let Some(Ok(frame)) = response_body.frame().await { - if let Some(data) = frame.data_ref() - && !data.is_empty() - { - on_chunk(data.as_ref()); - } - } - - let _ = producer_handle.await; -} - -/// Minimal `http_body::Body` implementation backed by an mpsc -/// `Receiver` — used by [`dispatch_bidirectional_streaming`] -/// to feed request body chunks into axum. -struct ChannelBody { - rx: tokio::sync::mpsc::Receiver, -} - -impl HttpBody for ChannelBody { - type Data = Bytes; - type Error = Infallible; - - fn poll_frame( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - ) -> Poll, Self::Error>>> { - match self.rx.poll_recv(cx) { - Poll::Ready(Some(bytes)) => Poll::Ready(Some(Ok(Frame::data(bytes)))), - Poll::Ready(None) => Poll::Ready(None), - Poll::Pending => Poll::Pending, - } - } -} - -/// Parse a wire-format request. On success returns the deserialised -/// header and the owned body bytes (zero-copy via `Vec::split_off`). -fn parse_wire_request(mut input: Vec) -> Result<(WireRequestHeader, Vec), String> { - if input.len() < 4 { - return Err(format!( - "wire input too short: {} bytes, need at least 4", - input.len() - )); - } - let mut len_bytes = [0u8; 4]; - len_bytes.copy_from_slice(&input[..4]); - let header_len = u32::from_be_bytes(len_bytes) as usize; - let total_header_end = 4usize.saturating_add(header_len); - if total_header_end > input.len() { - return Err(format!( - "wire header_len ({header_len}) exceeds remaining input ({} bytes)", - input.len() - 4 - )); - } - // Take ownership of the body without copy. - let body = input.split_off(total_header_end); - let header_json = &input[4..total_header_end]; - let header: WireRequestHeader = serde_json::from_slice(header_json) - .map_err(|e| format!("wire header JSON parse error: {e}"))?; - Ok((header, body)) -} +pub use config::{ + DEFAULT_STREAMING_CHANNEL_CAPACITY, DEFAULT_STREAMING_CHUNK_BYTES, max_request_bytes, + request_exceeds_limit, set_max_request_bytes, set_streaming_channel_capacity, + set_streaming_chunk_bytes, streaming_channel_capacity, streaming_chunk_bytes, +}; +pub use dispatch::{ + DirectWriteResult, dispatch, dispatch_from_bytes, dispatch_from_bytes_async, dispatch_into, + dispatch_into_async, dispatch_owned, dispatch_typed, +}; +pub use envelope::{ + HeaderValue, RequestEnvelope, ResponseEnvelope, ResponseMetadata, error_envelope, +}; +pub use registry::{DEFAULT_APP_NAME, register_app, register_app_named}; +pub use streaming::{ + RequestChunk, StreamAbort, dispatch_bidirectional_streaming, + dispatch_bidirectional_streaming_with_header, dispatch_streaming_async, + dispatch_streaming_with_header_async, +}; +pub use wire::error_wire; diff --git a/crates/vespera_inprocess/src/registry.rs b/crates/vespera_inprocess/src/registry.rs new file mode 100644 index 00000000..a3404e98 --- /dev/null +++ b/crates/vespera_inprocess/src/registry.rs @@ -0,0 +1,211 @@ +//! App registry: named `Router` factories with a lock-free +//! `OnceLock` fast path for the default app. + +use std::collections::HashMap; +use std::sync::{LazyLock, OnceLock, RwLock}; + +use crate::Router; +use crate::wire::{WireRequestHeader, error_wire}; + +/// Canonical name of the default app — used when the wire header +/// omits `"app"` or sets it to an empty string, and when callers use +/// the BC [`register_app`] entry point. +pub const DEFAULT_APP_NAME: &str = "_default"; + +/// Maximum allowed length of an app name (after trimming). Sized so +/// names fit comfortably in URL path segments and log lines. +const MAX_APP_NAME_LEN: usize = 64; + +// ── App Factory (shared FFI pattern) ───────────────────────────────── + +/// Per-name router cache. Indexed by app name; the default app uses +/// [`DEFAULT_APP_NAME`] (`"_default"`). +/// +/// Uses [`RwLock`] (not [`OnceLock`]) so multiple named apps can be +/// registered after init time, while keeping dispatch reads +/// contention-free. The map is read on every dispatch and written +/// only during `register_app*` calls (typically at process startup). +/// +/// Lock poisoning recovery: every read path uses +/// `unwrap_or_else(|e| e.into_inner())` so a panic in a producer +/// thread does not lock out the dispatch hot path. Factory closures +/// are also invoked **outside** the write lock so a factory panic +/// cannot poison the map. +static APP_ROUTERS: LazyLock>> = + LazyLock::new(|| RwLock::new(HashMap::new())); + +/// Lock-free fast path for the **default** app. +/// +/// The overwhelmingly common dispatch case is a wire header without +/// an `"app"` field — routing to [`DEFAULT_APP_NAME`]. Resolving it +/// through `APP_ROUTERS` costs an `RwLock` read acquisition per +/// request, which parks threads under high concurrency. This +/// `OnceLock` mirror is set (exactly once, inside the registration +/// write lock so it can never diverge from the map) by the first +/// successful `_default` registration and read with a single atomic +/// load + `Router::clone` (`Arc` refcount bump) on every dispatch. +/// +/// Named apps keep using the `RwLock` — they are the rare +/// multi-app case and can be registered at any time. +static DEFAULT_ROUTER: OnceLock = OnceLock::new(); + +/// Validate an app name for registration / lookup. +/// +/// Constraints: +/// - non-empty after trimming whitespace +/// - at most [`MAX_APP_NAME_LEN`] bytes +/// - ASCII alphanumeric, `_`, or `-` only +/// +/// Returns the trimmed name on success. +fn validate_app_name(name: &str) -> Result<&str, String> { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("app name must not be empty".to_owned()); + } + if trimmed.len() > MAX_APP_NAME_LEN { + return Err(format!( + "app name too long: {} chars (max {MAX_APP_NAME_LEN})", + trimmed.len() + )); + } + if !trimmed + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err(format!( + "app name '{trimmed}' contains invalid characters (allowed: alphanumeric, '_', '-')" + )); + } + Ok(trimmed) +} + +/// Register the **default** global router factory. +/// +/// Equivalent to `register_app_named(DEFAULT_APP_NAME, factory)`. +/// Wire requests without an `"app"` header (or with `"app": ""`) are +/// routed here. +/// +/// Any FFI boundary (JNI, C, WASM) calls this once at init time, then +/// uses [`dispatch_from_bytes`] on each request. +/// +/// # Second-call semantics +/// +/// Calling `register_app` more than once is a **no-op** — the first +/// registration wins, the new factory closure is NOT invoked. Friendly +/// for environments that legitimately load the cdylib twice (hot-reloading +/// JVM hosts, plugin systems). +pub fn register_app(factory: F) +where + F: Fn() -> Router + Send + Sync + 'static, +{ + register_app_named(DEFAULT_APP_NAME, factory); +} + +/// Register a **named** global router factory for multi-app routing. +/// +/// Wire requests carrying `"app": ""` in their header are +/// dispatched to this router. Multiple named apps can coexist in +/// the same process; register each once at init time. +/// +/// # First-wins per name +/// +/// Calling this more than once with the same `name` is a no-op — the +/// first registration wins. Registering different names is the +/// supported multi-app pattern. +/// +/// # Panic safety +/// +/// The `factory` closure is invoked **outside** the internal +/// `RwLock`'s write guard. A panic in `factory` cannot poison the +/// map; the registration is simply discarded and the slot remains +/// available for retry. +/// +/// # Invalid names +/// +/// Names that fail [`validate_app_name`] (empty, > 64 bytes, or +/// containing characters outside `[A-Za-z0-9_-]`) are silently +/// discarded — registration is a no-op. Dispatch with a matching +/// invalid name will return a `400` wire response. +pub fn register_app_named(name: &str, factory: F) +where + F: Fn() -> Router + Send + Sync + 'static, +{ + let name = match validate_app_name(name) { + Ok(n) => n.to_owned(), + Err(_) => return, + }; + // Fast path: existence check under a read lock. + { + let map = APP_ROUTERS + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if map.contains_key(&name) { + return; + } + } + // Build the router OUTSIDE the write lock so a panicking factory + // cannot poison the map. + let router = factory(); + let is_default = name == DEFAULT_APP_NAME; + let mut map = APP_ROUTERS + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // Double-check: another thread may have inserted between our read + // and write. First-wins still holds — use Entry to avoid the + // map.contains_key + map.insert double lookup. + let stored = map.entry(name).or_insert(router); + if is_default { + // Mirror the default app into the lock-free fast path. Done + // under the write lock with the *stored* router (not our local + // candidate) so the mirror always equals the map's first-wins + // winner, even when two threads race the registration. + let _ = DEFAULT_ROUTER.set(stored.clone()); + } +} + +/// Resolve a [`Router`] for a wire request, applying default-app +/// fallback and name validation. Returns the cloned router (cheap — +/// axum's router is `Arc`-backed) on success, or a wire error response +/// (`400` for invalid name, `404` for unregistered name) on failure. +/// +/// Lookup-first: registered names are validated at registration time +/// ([`register_app_named`] discards invalid names), so a map hit is +/// valid by construction. Validation runs only on a miss, purely to +/// pick the right error status (`400` invalid vs `404` unregistered) +/// — keeping the per-request hot path to trim + hash lookup. +#[inline] +pub fn resolve_app_router(header: &WireRequestHeader) -> Result> { + let name = header + .app + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(DEFAULT_APP_NAME); + // Lock-free fast path: default-app dispatch (the common case) + // resolves with one atomic load — no RwLock acquisition. + if name == DEFAULT_APP_NAME + && let Some(router) = DEFAULT_ROUTER.get() + { + return Ok(router.clone()); + } + { + let map = APP_ROUTERS + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(router) = map.get(name) { + return Ok(router.clone()); + } + } + // Miss: decide between 400 (invalid name) and 404 (unregistered). + match validate_app_name(name) { + Err(msg) => Err(error_wire(400, &format!("invalid app name: {msg}"))), + Ok(name) => Err(error_wire( + 404, + &format!( + "no app registered with name '{name}' — \ + use register_app() for the default app or \ + register_app_named(name, factory) for additional apps" + ), + )), + } +} diff --git a/crates/vespera_inprocess/src/streaming.rs b/crates/vespera_inprocess/src/streaming.rs new file mode 100644 index 00000000..281b42f1 --- /dev/null +++ b/crates/vespera_inprocess/src/streaming.rs @@ -0,0 +1,518 @@ +//! Streaming dispatch variants: response streaming, header-callback +//! streaming, and bidirectional (request + response) streaming. + +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; + +use axum::body::Body; +use bytes::Bytes; +use http_body::{Body as HttpBody, Frame}; +use http_body_util::BodyExt; + +use crate::config::streaming_channel_capacity; +use crate::internal::{dispatch_and_split, dispatch_response_streaming}; +use crate::registry::resolve_app_router; +use crate::wire::{ + WIRE_HEADER_RESERVE, WIRE_VERSION, build_wire_header_bytes, error_wire, parse_wire_header, + split_wire_request, +}; + +/// Outcome of one request-body pull on the bidirectional streaming +/// path (the `pull_chunk` callback). +/// +/// `Data(empty)` means "nothing right now, keep the stream open" — it +/// is skipped, not treated as EOF. [`RequestChunk::Error`] terminates +/// the request body with a [`StreamAbort`] so axum and the handler see +/// a failed body rather than a clean EOF — a truncated upload (e.g. the +/// source `InputStream` threw mid-stream) is never silently accepted as +/// complete. +pub enum RequestChunk { + /// A request body chunk (an empty vec is a no-op "keep open" signal). + Data(Vec), + /// Clean end of the request body. + End, + /// The producer failed; the request body errors out instead of + /// ending cleanly. + Error, +} + +/// Upper bound on consecutive empty request-body pulls before the +/// producer aborts the stream. A conformant blocking `InputStream` +/// never returns 0 for a non-empty buffer, so sustained empty reads +/// indicate a stuck or hostile producer; the cap stops a DoS busy-spin +/// on a blocking-pool thread. +const MAX_CONSECUTIVE_EMPTY_READS: u32 = 1024; + +/// Error yielded by the request body when the producer reports +/// [`RequestChunk::Error`]. Surfaced to axum so a truncated upload is +/// not mistaken for a complete one. +#[derive(Debug)] +pub struct StreamAbort; + +impl std::fmt::Display for StreamAbort { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("request body stream aborted by producer") + } +} + +impl std::error::Error for StreamAbort {} + +/// **Streaming** sibling of [`dispatch_from_bytes_async`]. +/// +/// Drives the dispatch end-to-end like the non-streaming variant but +/// emits the response body **chunk-by-chunk via `on_chunk`** instead +/// of materialising it in a single `Vec`. Returns the wire-format +/// header bytes only (`[u32 BE header_len | header JSON]`) — the body +/// is delivered through the callback while the dispatch is in flight, +/// so a 1 GiB response is never resident in memory. +/// +/// `on_chunk` is invoked one or more times in arrival order; the +/// borrowed slice is valid only for the duration of each call and the +/// callback should treat it as ephemeral (e.g. write it to an +/// `OutputStream`, accumulate it on disk, …). +/// +/// Failure modes are identical to [`dispatch_from_bytes_async`] — +/// returns a valid wire-format error response (header + body) when +/// the wire input is malformed, the version is wrong, no app is +/// registered, or the handler reports a pre-dispatch error. In the +/// error path the body is included inside the returned bytes (not +/// streamed via `on_chunk`) because the error message is small. +/// +/// `on_chunk` is NOT called if the response body is empty. +pub async fn dispatch_streaming_async(input: Vec, mut on_chunk: F) -> Vec +where + F: FnMut(&[u8]), +{ + let (header_bytes, body_bytes) = match split_wire_request(input) { + Ok(parts) => parts, + Err(msg) => return error_wire(400, &msg), + }; + let header = match parse_wire_header(&header_bytes) { + Ok(h) => h, + Err(msg) => return error_wire(400, &msg), + }; + if header.v != WIRE_VERSION { + return error_wire( + 400, + &format!( + "unsupported wire version: got {}, expected {WIRE_VERSION}", + header.v + ), + ); + } + let router = match resolve_app_router(&header) { + Ok(r) => r, + Err(wire) => return wire, + }; + let (status, headers, metadata) = match dispatch_response_streaming( + router, + &header.method, + &header.path, + &header.query, + header.headers.iter().map(|(k, v)| (k.as_ref(), v.as_ref())), + body_bytes, + &mut on_chunk, + ) + .await + { + Ok(parts) => parts, + Err((status, msg)) => return error_wire(status, &msg), + }; + // Emit header-only wire bytes; body was streamed via on_chunk. + // NOTE: the streaming path does not hoist 422 validation errors — + // hoisting requires materialising the full body, which is + // antithetical to the streaming contract. Callers needing + // validation hoisting should use dispatch_from_bytes_async. + build_wire_header_bytes(status, &headers, &metadata) +} + +/// **Streaming dispatch with explicit header callback** — emits the +/// wire-format response header via `on_header` **before** any body +/// chunk is delivered to `on_chunk`. +/// +/// This is the variant Spring `HttpServletResponse`-based controllers +/// want: `on_header` fires while the response is still uncommitted, +/// so the controller can call `resp.setStatus(...)` / +/// `resp.setHeader(...)` from the callback. Then `on_chunk` streams +/// the body bytes one frame at a time. +/// +/// `on_header` is called **exactly once** in every code path — +/// success or error. On error (malformed wire, no app, invalid +/// method, …) the bytes passed to `on_header` are a normal +/// `error_wire(...)` response and `on_chunk` is **not** invoked. +pub async fn dispatch_streaming_with_header_async( + input: Vec, + mut on_header: H, + mut on_chunk: F, +) where + H: FnMut(&[u8]), + F: FnMut(&[u8]), +{ + let (header_bytes, body_bytes) = match split_wire_request(input) { + Ok(parts) => parts, + Err(msg) => { + on_header(&error_wire(400, &msg)); + return; + } + }; + let header = match parse_wire_header(&header_bytes) { + Ok(h) => h, + Err(msg) => { + on_header(&error_wire(400, &msg)); + return; + } + }; + if header.v != WIRE_VERSION { + on_header(&error_wire( + 400, + &format!( + "unsupported wire version: got {}, expected {WIRE_VERSION}", + header.v + ), + )); + return; + } + let router = match resolve_app_router(&header) { + Ok(r) => r, + Err(wire) => { + on_header(&wire); + return; + } + }; + + let (status, headers, metadata, mut body) = match dispatch_and_split( + router, + &header.method, + &header.path, + &header.query, + header.headers.iter().map(|(k, v)| (k.as_ref(), v.as_ref())), + Body::from(body_bytes), + false, + ) + .await + { + Ok(parts) => parts, + Err((status, msg)) => { + on_header(&error_wire(status, &msg)); + return; + } + }; + + on_header(&build_wire_header_bytes(status, &headers, &metadata)); + + while let Some(Ok(frame)) = body.frame().await { + if let Some(data) = frame.data_ref() + && !data.is_empty() + { + on_chunk(data.as_ref()); + } + } +} + +/// **Bidirectional streaming dispatch** — both request and response +/// bodies are streamed chunk-by-chunk; neither side materialises the +/// full payload in memory. +/// +/// - `input_header` is a wire-format request **without a body** +/// (just `[u32 BE header_len | JSON header]`). Send the body +/// chunks via `pull_chunk`, not embedded in this buffer. +/// - `pull_chunk` is called repeatedly to obtain request body +/// chunks. Return [`RequestChunk::Data`] for each chunk and +/// [`RequestChunk::End`] to signal clean EOF. An empty +/// `Data(Vec::new())` is treated as "no more data right now, but +/// keep the stream open" — rarely useful; most callers should just +/// return `End`. Return [`RequestChunk::Error`] to abort the +/// request body (e.g. the source stream threw) so the truncated +/// upload is rejected rather than seen as complete. +/// - `on_chunk` receives response body chunks in arrival order, same +/// contract as [`dispatch_streaming_async`]. +/// +/// Returns the wire-format **header only** (`[u32 BE header_len | +/// header JSON]`) — the response body was delivered via `on_chunk`. +/// +/// `pull_chunk` runs on a Tokio blocking thread (`spawn_blocking`) +/// because the JNI implementation reads from a Java `InputStream`, +/// which is inherently blocking. That blocking producer is started +/// lazily on the first request-body poll, so handlers that never read +/// the body never touch the `InputStream`. Backpressure is enforced by +/// a bounded mpsc channel ([`streaming_channel_capacity`] slots, +/// default 16): if axum reads slowly, the `pull_chunk` call blocks +/// naturally. +/// +/// Failure modes match [`dispatch_streaming_async`]: malformed +/// header / unknown version / no app / handler error → normal +/// `error_wire(...)` response (with the message inside the returned +/// bytes); neither callback is invoked in those paths. +pub async fn dispatch_bidirectional_streaming( + input_header: Vec, + pull_chunk: P, + on_chunk: F, +) -> Vec +where + P: FnMut() -> RequestChunk + Send + 'static, + F: FnMut(&[u8]), +{ + let mut header_bytes: Vec = Vec::with_capacity(4 + WIRE_HEADER_RESERVE); + { + let on_header = |h: &[u8]| header_bytes.extend_from_slice(h); + bidirectional_streaming_inner(input_header, pull_chunk, on_chunk, on_header).await; + } + header_bytes +} + +/// **Bidirectional streaming with explicit header callback** — the +/// `with_header` counterpart of [`dispatch_bidirectional_streaming`]. +/// Emits the wire-format response header via `on_header` **before** +/// any response body byte reaches `on_chunk`, so Spring-style +/// `HttpServletResponse` controllers can commit status / headers +/// from the callback while the response is still uncommitted. +/// +/// `on_header` is called exactly once on every code path (success or +/// error). On any pre-dispatch / wire error the bytes passed to +/// `on_header` are a normal `error_wire(...)` response and neither +/// `pull_chunk` nor `on_chunk` is invoked beyond that point. +pub async fn dispatch_bidirectional_streaming_with_header( + input_header: Vec, + pull_chunk: P, + on_chunk: F, + on_header: H, +) where + P: FnMut() -> RequestChunk + Send + 'static, + F: FnMut(&[u8]), + H: FnMut(&[u8]), +{ + bidirectional_streaming_inner(input_header, pull_chunk, on_chunk, on_header).await; +} + +async fn bidirectional_streaming_inner( + input_header: Vec, + pull_chunk: P, + mut on_chunk: F, + mut on_header: H, +) where + P: FnMut() -> RequestChunk + Send + 'static, + F: FnMut(&[u8]), + H: FnMut(&[u8]), +{ + let (header_bytes, _ignored_body) = match split_wire_request(input_header) { + Ok(parts) => parts, + Err(msg) => { + on_header(&error_wire(400, &msg)); + return; + } + }; + let header = match parse_wire_header(&header_bytes) { + Ok(h) => h, + Err(msg) => { + on_header(&error_wire(400, &msg)); + return; + } + }; + if header.v != WIRE_VERSION { + on_header(&error_wire( + 400, + &format!( + "unsupported wire version: got {}, expected {WIRE_VERSION}", + header.v + ), + )); + return; + } + let router = match resolve_app_router(&header) { + Ok(r) => r, + Err(wire) => { + on_header(&wire); + return; + } + }; + + let producer_handle: RequestProducerHandle = Arc::new(Mutex::new(None)); + let body = Body::new(ChannelBody::new(pull_chunk, Arc::clone(&producer_handle))); + let (status, headers, metadata, mut response_body) = match dispatch_and_split( + router, + &header.method, + &header.path, + &header.query, + header.headers.iter().map(|(k, v)| (k.as_ref(), v.as_ref())), + body, + false, + ) + .await + { + Ok(parts) => parts, + Err((status, msg)) => { + await_request_producer(&producer_handle).await; + on_header(&error_wire(status, &msg)); + return; + } + }; + + on_header(&build_wire_header_bytes(status, &headers, &metadata)); + + while let Some(Ok(frame)) = response_body.frame().await { + if let Some(data) = frame.data_ref() + && !data.is_empty() + { + on_chunk(data.as_ref()); + } + } + + await_request_producer(&producer_handle).await; +} + +type RequestProducerHandle = Arc>>>; +type PullChunk = Box RequestChunk + Send + 'static>; +type RequestFrame = Result; + +struct RequestProducer { + pull_chunk: PullChunk, + capacity: usize, +} + +/// Minimal `http_body::Body` implementation backed by an mpsc +/// `Receiver>` — used by +/// [`dispatch_bidirectional_streaming`] to feed request body chunks +/// into axum. A producer error is forwarded as a body error so a +/// truncated upload is not seen as a clean EOF. +struct ChannelBody { + rx: Option>, + producer: Option, + producer_handle: RequestProducerHandle, +} + +impl ChannelBody { + fn new

(pull_chunk: P, producer_handle: RequestProducerHandle) -> Self + where + P: FnMut() -> RequestChunk + Send + 'static, + { + Self { + rx: None, + producer: Some(RequestProducer { + pull_chunk: Box::new(pull_chunk), + capacity: streaming_channel_capacity(), + }), + producer_handle, + } + } + + fn start_producer_if_needed(&mut self) { + if self.rx.is_some() { + return; + } + + let Some(producer) = self.producer.take() else { + return; + }; + + // Bounded mpsc (default 16 slots, see streaming_channel_capacity) + // — gives natural backpressure between the pull_chunk producer + // thread and the axum handler consumer. The channel is created + // with the producer so unpolled bodies avoid both pieces of setup. + let (tx, rx) = tokio::sync::mpsc::channel::(producer.capacity); + self.rx = Some(rx); + let handle = spawn_request_producer(producer.pull_chunk, tx); + store_request_producer_handle(&self.producer_handle, handle); + } +} + +impl HttpBody for ChannelBody { + type Data = Bytes; + type Error = StreamAbort; + + fn poll_frame( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + self.start_producer_if_needed(); + + let Some(rx) = self.rx.as_mut() else { + return Poll::Ready(None); + }; + + match rx.poll_recv(cx) { + Poll::Ready(Some(Ok(bytes))) => Poll::Ready(Some(Ok(Frame::data(bytes)))), + // Producer reported an abort: surface it as a body error so + // axum/the handler rejects the truncated upload. + Poll::Ready(Some(Err(abort))) => Poll::Ready(Some(Err(abort))), + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } + } +} + +fn spawn_request_producer( + mut pull: PullChunk, + tx: tokio::sync::mpsc::Sender, +) -> tokio::task::JoinHandle<()> { + tokio::task::spawn_blocking(move || { + // `End` ends the stream; an empty `Data(_)` is skipped (it's not + // EOF); `Error` forwards a `StreamAbort` so the body errors out + // instead of ending cleanly. A failed `blocking_send` means the + // receiver — axum's request body — was dropped because the + // handler aborted mid-stream, so we stop pulling. + let mut consecutive_empty: u32 = 0; + loop { + match pull() { + RequestChunk::Data(chunk) => { + if chunk.is_empty() { + // A conformant blocking `InputStream.read(byte[])` + // never returns 0 for a non-empty buffer — it + // blocks until ≥1 byte or returns -1 at EOF. + // Sustained empty reads therefore mean a stuck or + // hostile producer; cap them (with a yield so we + // don't peg a blocking-pool core) and abort instead + // of busy-spinning this thread forever. + consecutive_empty += 1; + if consecutive_empty >= MAX_CONSECUTIVE_EMPTY_READS { + let _ = tx.blocking_send(Err(StreamAbort)); + break; + } + std::thread::yield_now(); + continue; + } + consecutive_empty = 0; + if tx.blocking_send(Ok(Bytes::from(chunk))).is_err() { + break; + } + } + RequestChunk::End => break, + RequestChunk::Error => { + // Best-effort: if the receiver is already gone there + // is nothing to abort. + let _ = tx.blocking_send(Err(StreamAbort)); + break; + } + } + } + // tx dropped at end of scope → axum sees end-of-stream (or the + // forwarded error above). + }) +} + +fn store_request_producer_handle( + producer_handle: &RequestProducerHandle, + handle: tokio::task::JoinHandle<()>, +) { + match producer_handle.lock() { + Ok(mut guard) => *guard = Some(handle), + Err(poisoned) => { + let mut guard = poisoned.into_inner(); + *guard = Some(handle); + } + } +} + +async fn await_request_producer(producer_handle: &RequestProducerHandle) { + let handle = match producer_handle.lock() { + Ok(mut guard) => guard.take(), + Err(poisoned) => { + let mut guard = poisoned.into_inner(); + guard.take() + } + }; + + if let Some(handle) = handle { + let _ = handle.await; + } +} diff --git a/crates/vespera_inprocess/src/wire.rs b/crates/vespera_inprocess/src/wire.rs new file mode 100644 index 00000000..6bad34fb --- /dev/null +++ b/crates/vespera_inprocess/src/wire.rs @@ -0,0 +1,472 @@ +//! Binary wire format: request-header borrowing deserialization, +//! response-header serialization (straight from `http::HeaderMap`), +//! frame split/parse, and 422 `validation_errors` hoisting. +//! +//! The serialized byte layout is **locked** by tests/wire_contract.rs. + +use std::borrow::Cow; + +use bytes::Bytes; +use serde::{Deserialize, Serialize}; + +use crate::envelope::ResponseMetadata; +use crate::internal::ResponseParts; + +#[cfg(test)] +mod tests { + use std::borrow::Cow; + + use super::{parse_wire_header, split_wire_request}; + + /// Pins the zero-copy contract: the returned body must point into + /// the original input allocation (no memcpy of the tail). + #[test] + fn split_wire_request_body_is_zero_copy() { + let header = br#"{"v":1,"method":"POST","path":"/x"}"#; + let body = vec![0xABu8; 1024]; + let mut wire = Vec::new(); + wire.extend_from_slice(&u32::try_from(header.len()).unwrap().to_be_bytes()); + wire.extend_from_slice(header); + wire.extend_from_slice(&body); + + let input_ptr = wire.as_ptr() as usize; + let body_offset = 4 + header.len(); + let (_, parsed_body) = split_wire_request(wire).expect("valid wire request"); + + assert_eq!(parsed_body.len(), 1024); + assert_eq!( + parsed_body.as_ptr() as usize, + input_ptr + body_offset, + "body must alias the original input buffer (zero-copy)" + ); + } + + /// Pins the borrowed-deserialization contract: header strings + /// without JSON escapes must borrow straight from the wire bytes + /// (no per-string allocation), with `Cow::Owned` reserved for + /// escaped values. + #[test] + fn parse_wire_header_borrows_plain_strings() { + let header_json = + br#"{"v":1,"method":"POST","path":"/users","query":"a=1","headers":{"x-a":"plain","x-b":"esc\"aped"},"app":"admin"}"#; + let header = parse_wire_header(header_json).expect("valid header"); + + let header_value = |name: &str| { + header + .headers + .iter() + .find(|(k, _)| k == name) + .map(|(_, v)| v) + }; + + assert!(matches!(header.method, Cow::Borrowed("POST"))); + assert!(matches!(header.path, Cow::Borrowed("/users"))); + assert!(matches!(header.query, Cow::Borrowed("a=1"))); + assert!(matches!(header.app.as_ref(), Some(Cow::Borrowed("admin")))); + assert!(matches!(header_value("x-a"), Some(Cow::Borrowed("plain")))); + // Escaped value falls back to owned — correctness over borrow. + assert_eq!( + header_value("x-b").map(std::convert::AsRef::as_ref), + Some("esc\"aped") + ); + } +} + +/// Wire format protocol version. The JSON header's `v` field MUST +/// equal this for requests; responses always emit this value. +pub const WIRE_VERSION: u8 = 1; + +// ── Wire Format Types (internal) ───────────────────────────────────── + +/// Request wire header, deserialized **borrowing from the input +/// buffer**: every string field is a `Cow` that points straight into +/// the wire bytes (zero allocation) unless the JSON value contains +/// escape sequences, in which case deserialization transparently +/// falls back to an owned copy. +/// +/// Direct `Cow` fields borrow via serde-derive's `borrow` +/// special-casing; `headers` and `app` need the custom +/// [`de_cow_map`] / [`de_opt_cow`] deserializers because serde's +/// stock `Cow` impl inside containers always copies. +#[derive(Debug, Deserialize)] +pub struct WireRequestHeader<'a> { + /// Wire protocol version; clients MUST send 1. + #[serde(default)] + pub v: u8, + #[serde(borrow)] + pub method: Cow<'a, str>, + #[serde(borrow)] + pub path: Cow<'a, str>, + #[serde(default, borrow)] + pub query: Cow<'a, str>, + /// Request headers as a flat list — dispatch only ever *iterates* + /// them (never looks one up by key), so a `Vec` skips the + /// `HashMap` bucket allocation + per-key hashing entirely. + /// Repeated names are forwarded as repeated request headers + /// (valid HTTP; the previous `HashMap` silently kept the last + /// duplicate of a degenerate duplicate-key JSON header). + #[serde(default, borrow, deserialize_with = "de_cow_pairs")] + pub headers: CowPairs<'a>, + /// Optional name of the target app for multi-app routing. When + /// omitted (or empty), the request is dispatched to the default + /// app registered via [`register_app`]. Use [`register_app_named`] + /// to register additional named apps. + #[serde(default, borrow, deserialize_with = "de_opt_cow")] + pub app: Option>, +} + +/// `Cow` wrapper whose `Deserialize` impl borrows from the input +/// when the JSON string carries no escape sequences. +struct BorrowableCow<'a>(Cow<'a, str>); + +impl<'de> Deserialize<'de> for BorrowableCow<'de> { + fn deserialize>(deserializer: D) -> Result { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = BorrowableCow<'de>; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a string") + } + + fn visit_borrowed_str( + self, + v: &'de str, + ) -> Result { + Ok(BorrowableCow(Cow::Borrowed(v))) + } + + fn visit_str(self, v: &str) -> Result { + Ok(BorrowableCow(Cow::Owned(v.to_owned()))) + } + + fn visit_string(self, v: String) -> Result { + Ok(BorrowableCow(Cow::Owned(v))) + } + } + deserializer.deserialize_str(V) + } +} + +/// Flat list of `(name, value)` request-header pairs borrowing from +/// the wire input. +type CowPairs<'a> = Vec<(Cow<'a, str>, Cow<'a, str>)>; + +/// Deserialize a JSON object into a flat `Vec` of `(name, value)` +/// pairs whose strings borrow from the input where possible — one +/// `Vec` allocation instead of `HashMap` buckets + per-key hashing. +fn de_cow_pairs<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = CowPairs<'de>; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a map of strings") + } + + fn visit_map>( + self, + mut access: A, + ) -> Result { + let mut out = Vec::with_capacity(access.size_hint().unwrap_or(0)); + while let Some((k, v)) = + access.next_entry::, BorrowableCow<'de>>()? + { + out.push((k.0, v.0)); + } + Ok(out) + } + } + deserializer.deserialize_map(V) +} + +/// Deserialize an `Option` that borrows from the input where +/// possible. +fn de_opt_cow<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result>, D::Error> { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = Option>; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a string or null") + } + + fn visit_none(self) -> Result { + Ok(None) + } + + fn visit_unit(self) -> Result { + Ok(None) + } + + fn visit_some>( + self, + deserializer: D2, + ) -> Result { + BorrowableCow::deserialize(deserializer).map(|c| Some(c.0)) + } + } + deserializer.deserialize_option(V) +} + +// wire-order locked — field order defines the serialized wire header +// byte layout (`v`, `status`, `headers`, `metadata`, +// `validation_errors?`). See tests/wire_contract.rs. +#[derive(Debug, Serialize)] +struct WireResponseHeader<'a, H: Serialize> { + v: u8, + status: u16, + headers: &'a H, + metadata: &'a ResponseMetadata, + /// Validation errors hoisted from a 422 JSON body so Java decoders + /// can read them with a single header parse. `None` for any other + /// status; the original body is preserved verbatim regardless. + #[serde(skip_serializing_if = "Option::is_none")] + validation_errors: Option>, +} + +/// Zero-allocation serializer for response headers: renders an +/// [`http::HeaderMap`] as the wire's sorted name → value JSON map, +/// borrowing every name and value straight from the map. +/// +/// Byte-compatible with the previous `BTreeMap` +/// representation (locked by tests/wire_contract.rs): +/// - names sort in byte order (`HeaderName`s are lowercase ASCII, so +/// `sort_unstable` equals `BTreeMap` ordering) +/// - single-valued headers render as a JSON string, repeated names as +/// a JSON array in insertion order (the untagged `HeaderValue` +/// shape) +/// - non-UTF-8 header values render as `""` (same `unwrap_or("")` +/// behaviour as the old owned conversion) +struct WireHeaders<'a>(&'a http::HeaderMap); + +impl Serialize for WireHeaders<'_> { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeMap; + // `HeaderMap::keys` yields each distinct name exactly once; + // pre-size to the exact distinct-key count so the collect never + // reallocates. + let mut names: Vec<&str> = Vec::with_capacity(self.0.keys_len()); + names.extend(self.0.keys().map(http::HeaderName::as_str)); + names.sort_unstable(); + let mut map = serializer.serialize_map(Some(names.len()))?; + for name in names { + let mut values = self.0.get_all(name).iter(); + let first = values + .next() + .expect("HeaderMap::keys yields only present names"); + if values.next().is_none() { + map.serialize_entry(name, first.to_str().unwrap_or(""))?; + } else { + map.serialize_entry(name, &WireHeaderValues(self.0, name))?; + } + } + map.end() + } +} + +/// Serializes the repeated values of one header name as a JSON array. +struct WireHeaderValues<'a>(&'a http::HeaderMap, &'a str); + +impl Serialize for WireHeaderValues<'_> { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_seq( + self.0 + .get_all(self.1) + .iter() + .map(|v| v.to_str().unwrap_or("")), + ) + } +} + +/// Append `[u32 BE header_len | header JSON]` to `out`, serializing +/// the header view **directly into the output buffer** — no +/// intermediate `Vec` and no second memcpy of the header JSON. +/// +/// Typical wire headers are well under this reservation, so the +/// serializer usually writes without reallocating. +pub const WIRE_HEADER_RESERVE: usize = 192; + +fn write_wire_header_into(out: &mut Vec, view: &WireResponseHeader<'_, H>) { + out.extend_from_slice(&[0u8; 4]); + let start = out.len(); + serde_json::to_writer(&mut *out, view).expect("WireResponseHeader serialization is infallible"); + let header_len = + u32::try_from(out.len() - start).expect("response header JSON exceeds u32::MAX bytes"); + out[start - 4..start].copy_from_slice(&header_len.to_be_bytes()); +} + +/// One entry in the wire header's `validation_errors` array. Fields +/// are best-effort: missing values in the source body become `None`. +#[derive(Debug, Serialize)] +struct ValidationErrorItem { + path: String, + #[serde(skip_serializing_if = "Option::is_none")] + code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, +} + +/// Build a wire-format error response with a plain-text body. +/// +/// Used by [`dispatch_from_bytes`] for malformed input and by the +/// JNI bridge for panic fallback. The response always carries +/// `content-type: text/plain; charset=utf-8`. +#[must_use] +pub fn error_wire(status: u16, msg: &str) -> Vec { + let mut headers = http::HeaderMap::with_capacity(1); + headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("text/plain; charset=utf-8"), + ); + let metadata = ResponseMetadata::current(); + let parts = ( + status, + headers, + Bytes::copy_from_slice(msg.as_bytes()), + metadata, + ); + to_wire_bytes(parts) +} + +/// Adapter: response parts → wire-format bytes. Layout: +/// `[u32 BE header_len | JSON header | raw body]`. +/// +/// For `status == 422` JSON responses we **best-effort** hoist any +/// `{"errors": [...]}` payload into the wire header's +/// `validation_errors` field — Java decoders can read validation +/// failures with a single header parse, while the original body is +/// preserved verbatim for clients that still rely on it. +pub fn to_wire_bytes(parts: ResponseParts) -> Vec { + let (status, headers, body_bytes, metadata) = parts; + let validation_errors = if status == 422 { + try_hoist_validation_errors(&headers, &body_bytes) + } else { + None + }; + let header = WireResponseHeader { + v: WIRE_VERSION, + status, + headers: &WireHeaders(&headers), + metadata: &metadata, + validation_errors, + }; + let mut out = Vec::with_capacity(4 + WIRE_HEADER_RESERVE + body_bytes.len()); + write_wire_header_into(&mut out, &header); + out.extend_from_slice(&body_bytes); + out +} + +/// Build wire-format header bytes (`[u32 BE header_len | JSON header]`) +/// without a body — used by the `*_with_header` callback variants. +pub fn build_wire_header_bytes( + status: u16, + headers: &http::HeaderMap, + metadata: &ResponseMetadata, +) -> Vec { + let view = WireResponseHeader { + v: WIRE_VERSION, + status, + headers: &WireHeaders(headers), + metadata, + validation_errors: None, + }; + let mut out = Vec::with_capacity(4 + WIRE_HEADER_RESERVE); + write_wire_header_into(&mut out, &view); + out +} + +/// Best-effort extract validation errors from a 422 JSON body. +/// +/// Returns `None` (silently) for: +/// - non-JSON content-types (anything that doesn't end in `/json` or +/// `+json`) +/// - body bytes that don't parse as JSON +/// - JSON without an `errors` array, or with an empty array +/// +/// This is intentionally lenient — a malformed 422 body must never +/// degrade to a 5xx; the original body is still surfaced verbatim. +fn try_hoist_validation_errors( + headers: &http::HeaderMap, + body_bytes: &Bytes, +) -> Option> { + // First content-type value decides (matches the previous + // first-of-Multi behaviour). Comparisons are case-insensitive + // in place — no lowercased copy. + let is_json = headers + .get(http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|s| { + let mime = s.split(';').next().unwrap_or("").trim(); + mime.eq_ignore_ascii_case("application/json") + || (mime.len() >= 5 && mime[mime.len() - 5..].eq_ignore_ascii_case("+json")) + }); + if !is_json { + return None; + } + let parsed: serde_json::Value = serde_json::from_slice(body_bytes).ok()?; + let errors = parsed.get("errors")?.as_array()?; + let items: Vec = errors + .iter() + .filter_map(|e| { + let path = e.get("path")?.as_str()?.to_owned(); + let code = e + .get("code") + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + let message = e + .get("message") + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + Some(ValidationErrorItem { + path, + code, + message, + }) + }) + .collect(); + if items.is_empty() { None } else { Some(items) } +} + +/// Split a wire-format request into its header-JSON region and body — +/// both true zero-copy O(1) refcount views of the input allocation +/// (unlike `Vec::split_off`, which allocates a new vector and memcpys +/// the tail). +/// +/// Two-phase with [`parse_wire_header`] so the deserialized header +/// can **borrow** its strings from the returned header bytes (the +/// caller keeps them alive on its stack frame). +pub fn split_wire_request(input: Vec) -> Result<(Bytes, Bytes), String> { + if input.len() < 4 { + return Err(format!( + "wire input too short: {} bytes, need at least 4", + input.len() + )); + } + let mut input = Bytes::from(input); + let mut len_bytes = [0u8; 4]; + len_bytes.copy_from_slice(&input[..4]); + let header_len = u32::from_be_bytes(len_bytes) as usize; + let total_header_end = 4usize.saturating_add(header_len); + if total_header_end > input.len() { + return Err(format!( + "wire header_len ({header_len}) exceeds remaining input ({} bytes)", + input.len() - 4 + )); + } + // O(1) splits: all views share the original allocation. + let body = input.split_off(total_header_end); + let header_json = input.slice(4..); + Ok((header_json, body)) +} + +/// Deserialize the wire request header, borrowing every string from +/// `header_json` where possible (see [`WireRequestHeader`]). +#[inline] +pub fn parse_wire_header(header_json: &[u8]) -> Result, String> { + serde_json::from_slice(header_json).map_err(|e| format!("wire header JSON parse error: {e}")) +} diff --git a/crates/vespera_inprocess/tests/binary_wire.rs b/crates/vespera_inprocess/tests/binary_wire.rs index 5a40cc9a..4afe437d 100644 --- a/crates/vespera_inprocess/tests/binary_wire.rs +++ b/crates/vespera_inprocess/tests/binary_wire.rs @@ -24,7 +24,7 @@ use serde::Deserialize; use serde_json::Value; use std::sync::Mutex; use tokio::runtime::Builder; -use vespera_inprocess::{dispatch_from_bytes, register_app}; +use vespera_inprocess::{RequestChunk, dispatch_from_bytes, register_app}; // ── Test app ───────────────────────────────────────────────────────── @@ -311,6 +311,37 @@ async fn dispatch_streaming_async_large_binary_body() { ); } +#[test] +fn wire_response_bytes_are_deterministic_across_dispatches() { + // Response headers serialise from a BTreeMap — identical requests + // MUST produce byte-identical wire responses (golden-file / + // SHA-comparison safety). This pins the V2-C determinism + // guarantee; with the previous HashMap the JSON key order varied + // per response. + install_router(); + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + // /echo/bytes responds with content-type + content-length — + // multiple headers, which is what exposed the ordering issue. + let wire = encode_wire( + "POST", + "/echo/bytes", + None, + HashMap::from([("content-type", "application/octet-stream")]), + b"determinism-probe", + ); + let first = dispatch_from_bytes(wire.clone(), &runtime); + for run in 0..4 { + let again = dispatch_from_bytes(wire.clone(), &runtime); + assert_eq!( + first, again, + "wire response bytes must be identical on repeat dispatch (run {run})" + ); + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn dispatch_bidirectional_streaming_roundtrips_small_body() { install_router(); @@ -327,7 +358,13 @@ async fn dispatch_bidirectional_streaming_roundtrips_small_body() { // Request body chunks to push. let chunks: Vec> = vec![b"hello ".to_vec(), b"world".to_vec(), b"!".to_vec()]; let chunks_iter = Mutex::new(chunks.into_iter()); - let pull_chunk = move || -> Option> { chunks_iter.lock().unwrap().next() }; + let pull_chunk = move || -> RequestChunk { + chunks_iter + .lock() + .unwrap() + .next() + .map_or(RequestChunk::End, RequestChunk::Data) + }; // Response body sink. let received: std::sync::Arc>> = std::sync::Arc::new(Mutex::new(Vec::new())); @@ -352,6 +389,145 @@ async fn dispatch_bidirectional_streaming_roundtrips_small_body() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn dispatch_bidirectional_streaming_endless_empty_pull_aborts_not_hangs() { + install_router(); + + let header_only_wire = encode_wire( + "POST", + "/echo/bytes", + None, + HashMap::from([("content-type", "application/octet-stream")]), + &[], + ); + + // A hostile producer that ALWAYS reports an empty chunk (mirrors a + // non-conformant InputStream.read() returning 0 forever). Without + // the consecutive-empty cap this busy-spins the blocking-pool thread + // forever; with it, the producer aborts the body so the dispatch + // terminates. A timeout guards against regression to a hang. + let pull_chunk = || -> RequestChunk { RequestChunk::Data(Vec::new()) }; + let on_chunk = |_: &[u8]| {}; + + let dispatched = tokio::time::timeout( + std::time::Duration::from_secs(10), + vespera_inprocess::dispatch_bidirectional_streaming(header_only_wire, pull_chunk, on_chunk), + ) + .await; + + let header_bytes = dispatched.expect("dispatch must terminate, not busy-spin forever"); + let (header, _body) = decode_wire(&header_bytes); + assert_eq!( + header["status"].as_u64(), + Some(400), + "endless empty reads must abort the upload (400), not hang" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn dispatch_bidirectional_streaming_pull_error_aborts_upload() { + install_router(); + + let header_only_wire = encode_wire( + "POST", + "/echo/bytes", + None, + HashMap::from([("content-type", "application/octet-stream")]), + &[], + ); + + // First pull yields a chunk, the second reports a producer error + // (e.g. the source `InputStream` threw mid-upload). The body must + // abort so the handler's `Bytes` extractor fails — NOT be accepted + // as a clean EOF carrying the partial "hello ". + let counter = Mutex::new(0u32); + let pull_chunk = move || -> RequestChunk { + let mut g = counter.lock().unwrap(); + *g += 1; + match *g { + 1 => RequestChunk::Data(b"hello ".to_vec()), + _ => RequestChunk::Error, + } + }; + + let received: std::sync::Arc>> = std::sync::Arc::new(Mutex::new(Vec::new())); + let received_clone = std::sync::Arc::clone(&received); + let on_chunk = move |chunk: &[u8]| { + received_clone.lock().unwrap().extend_from_slice(chunk); + }; + + let header_bytes = + vespera_inprocess::dispatch_bidirectional_streaming(header_only_wire, pull_chunk, on_chunk) + .await; + + let (header, _body) = decode_wire(&header_bytes); + // axum's `Bytes` extractor rejects a body that errors mid-stream + // (400), instead of the 200 echo of the partial "hello " that the + // old silent-EOF behaviour would have produced. + assert_eq!( + header["status"].as_u64(), + Some(400), + "a producer error must reject the upload, not silently complete it" + ); + // Whatever streams back is axum's 400 rejection body — never the + // partial "hello " echoed as a successful upload. + let echoed = received.lock().unwrap().clone(); + assert_ne!( + echoed.as_slice(), + b"hello ", + "the aborted upload must not be echoed back as a completed body" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn dispatch_bidirectional_streaming_empty_chunk_is_retry_not_eof() { + // Pins the pull contract relied on by the JNI bridge: + // `Some(vec![])` means "no data right now, keep pulling" (mirrors + // Java `InputStream.read(byte[]) == 0`), NOT end-of-stream. Data + // arriving AFTER an empty chunk must still reach the handler. + install_router(); + + let header_only_wire = encode_wire( + "POST", + "/echo/bytes", + None, + HashMap::from([("content-type", "application/octet-stream")]), + &[], + ); + + let chunks: Vec> = vec![ + b"before".to_vec(), + Vec::new(), // empty read — must be skipped, not treated as EOF + b" after".to_vec(), + ]; + let chunks_iter = Mutex::new(chunks.into_iter()); + let pull_chunk = move || -> RequestChunk { + chunks_iter + .lock() + .unwrap() + .next() + .map_or(RequestChunk::End, RequestChunk::Data) + }; + + let received: std::sync::Arc>> = std::sync::Arc::new(Mutex::new(Vec::new())); + let received_clone = std::sync::Arc::clone(&received); + let on_chunk = move |chunk: &[u8]| { + received_clone.lock().unwrap().extend_from_slice(chunk); + }; + + let header_bytes = + vespera_inprocess::dispatch_bidirectional_streaming(header_only_wire, pull_chunk, on_chunk) + .await; + + let (header, _body) = decode_wire(&header_bytes); + assert_eq!(header["status"].as_u64(), Some(200)); + assert_eq!( + String::from_utf8_lossy(&received.lock().unwrap()), + "before after", + "data after an empty pull chunk must still reach the handler" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn dispatch_bidirectional_streaming_large_request_body() { install_router(); @@ -378,7 +554,13 @@ async fn dispatch_bidirectional_streaming_large_request_body() { .collect(); let expected: Vec = request_chunks.iter().flatten().copied().collect(); let chunks_iter = Mutex::new(request_chunks.into_iter()); - let pull_chunk = move || -> Option> { chunks_iter.lock().unwrap().next() }; + let pull_chunk = move || -> RequestChunk { + chunks_iter + .lock() + .unwrap() + .next() + .map_or(RequestChunk::End, RequestChunk::Data) + }; let received: std::sync::Arc>> = std::sync::Arc::new(Mutex::new(Vec::new())); let received_clone = std::sync::Arc::clone(&received); @@ -405,7 +587,7 @@ async fn dispatch_bidirectional_streaming_large_request_body() { async fn dispatch_bidirectional_streaming_emits_error_wire_on_malformed_header() { install_router(); let bad_header: Vec = vec![0u8, 0, 0, 99]; // overflow - let pull = || -> Option> { None }; + let pull = || -> RequestChunk { RequestChunk::End }; let on = |_: &[u8]| {}; let header_bytes = diff --git a/crates/vespera_inprocess/tests/dispatch_into.rs b/crates/vespera_inprocess/tests/dispatch_into.rs new file mode 100644 index 00000000..f50aef5a --- /dev/null +++ b/crates/vespera_inprocess/tests/dispatch_into.rs @@ -0,0 +1,233 @@ +//! Integration tests for the direct-write dispatch API +//! ([`vespera_inprocess::dispatch_into_async`]) — the +//! zero-materialisation path used by the JNI direct-buffer symbol. + +use std::sync::Once; + +use axum::Json; +use axum::Router; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use bytes::Bytes; +use serde_json::{Value, json}; +use tokio::runtime::Builder; +use vespera_inprocess::{DirectWriteResult, dispatch_from_bytes, dispatch_into, register_app}; + +async fn ping() -> &'static str { + "pong" +} + +async fn echo(body: Bytes) -> Bytes { + body +} + +/// Mimics the `Validated` 422 contract: JSON body with an `errors` +/// array — the wire layer must hoist it into the response header. +async fn reject() -> (StatusCode, Json) { + ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(json!({"errors": [{"path": "name", "message": "too short"}]})), + ) +} + +fn install() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + register_app(|| { + Router::new() + .route("/ping", get(ping)) + .route("/echo", post(echo)) + .route("/reject", post(reject)) + }); + }); +} + +fn encode(method: &str, path: &str, body: &[u8]) -> Vec { + let header = json!({ + "v": 1, + "method": method, + "path": path, + "headers": {"content-type": "application/octet-stream"}, + }); + let header_bytes = serde_json::to_vec(&header).unwrap(); + let mut wire = Vec::with_capacity(4 + header_bytes.len() + body.len()); + wire.extend_from_slice(&u32::try_from(header_bytes.len()).unwrap().to_be_bytes()); + wire.extend_from_slice(&header_bytes); + wire.extend_from_slice(body); + wire +} + +fn decode(wire: &[u8]) -> (Value, Vec) { + let header_len = u32::from_be_bytes(wire[..4].try_into().unwrap()) as usize; + let header: Value = serde_json::from_slice(&wire[4..4 + header_len]).unwrap(); + (header, wire[4 + header_len..].to_vec()) +} + +fn runtime() -> tokio::runtime::Runtime { + Builder::new_current_thread().enable_all().build().unwrap() +} + +#[test] +fn complete_matches_dispatch_from_bytes_exactly() { + install(); + let rt = runtime(); + let body = vec![0xCDu8; 32 * 1024]; + let wire = encode("POST", "/echo", &body); + + let reference = dispatch_from_bytes(wire.clone(), &rt); + let mut out = vec![0u8; reference.len() + 64]; + let result = dispatch_into(wire, &mut out, &rt); + + // V2-C determinism makes byte-equality a valid assertion. + assert_eq!(result, DirectWriteResult::Complete(reference.len())); + assert_eq!(&out[..reference.len()], &reference[..]); +} + +#[test] +fn exact_fit_boundary() { + install(); + let rt = runtime(); + let wire = encode("GET", "/ping", &[]); + let reference = dispatch_from_bytes(wire.clone(), &rt); + + let mut out = vec![0u8; reference.len()]; + let result = dispatch_into(wire, &mut out, &rt); + assert_eq!(result, DirectWriteResult::Complete(reference.len())); + assert_eq!(out, reference); +} + +#[test] +fn overflow_reports_exact_required_size() { + install(); + let rt = runtime(); + let body = vec![0xABu8; 100 * 1024]; + let wire = encode("POST", "/echo", &body); + let reference_len = dispatch_from_bytes(wire.clone(), &rt).len(); + + // Out buffer big enough for the header but not the body. + let mut out = vec![0u8; 256]; + let result = dispatch_into(wire.clone(), &mut out, &rt); + assert_eq!(result, DirectWriteResult::Overflow(reference_len)); + + // Header smaller than even the wire header → still exact. + let mut tiny = vec![0u8; 4]; + let result = dispatch_into(wire, &mut tiny, &rt); + assert_eq!(result, DirectWriteResult::Overflow(reference_len)); +} + +#[test] +fn status_422_preserves_validation_error_hoisting() { + install(); + let rt = runtime(); + let wire = encode("POST", "/reject", b"{}"); + + let reference = dispatch_from_bytes(wire.clone(), &rt); + let (ref_header, _) = decode(&reference); + assert!( + ref_header["validation_errors"].is_array(), + "precondition: byte path hoists validation_errors" + ); + + let mut out = vec![0u8; reference.len() + 64]; + let DirectWriteResult::Complete(n) = dispatch_into(wire, &mut out, &rt) else { + panic!("422 must fit"); + }; + assert_eq!( + &out[..n], + &reference[..], + "422 direct path must be byte-identical to dispatch_from_bytes \ + (hoisting + body verbatim)" + ); + let (header, body) = decode(&out[..n]); + assert_eq!(header["status"].as_u64(), Some(422)); + assert!( + header["validation_errors"].is_array(), + "hoisted validation_errors present" + ); + assert!(!body.is_empty(), "original 422 body preserved verbatim"); +} + +#[test] +fn pre_dispatch_errors_write_error_wire_into_out() { + install(); + let rt = runtime(); + + // Unknown app → 404 wire response written into out. + let header = json!({"v": 1, "method": "GET", "path": "/ping", "app": "ghost"}); + let header_bytes = serde_json::to_vec(&header).unwrap(); + let mut wire = u32::try_from(header_bytes.len()) + .unwrap() + .to_be_bytes() + .to_vec(); + wire.extend_from_slice(&header_bytes); + + let mut out = vec![0u8; 4096]; + let DirectWriteResult::Complete(n) = dispatch_into(wire, &mut out, &rt) else { + panic!("error wire must fit in 4096 bytes"); + }; + let (resp_header, body) = decode(&out[..n]); + assert_eq!(resp_header["status"].as_u64(), Some(404)); + assert!(String::from_utf8_lossy(&body).contains("ghost")); + + // Bad wire version → 400. + let bad = encode("GET", "/ping", &[]); + let mut bad = bad; + // Patch "v":1 → "v":9 inside the JSON header. + let pos = bad + .windows(4) + .position(|w| w == b"\"v\":") + .expect("v field present"); + bad[pos + 4] = b'9'; + let DirectWriteResult::Complete(n) = dispatch_into(bad, &mut out, &rt) else { + panic!("400 wire must fit"); + }; + let (resp_header, _) = decode(&out[..n]); + assert_eq!(resp_header["status"].as_u64(), Some(400)); +} + +#[test] +fn overflow_then_retry_with_exact_size_succeeds() { + install(); + let rt = runtime(); + let body = vec![0x42u8; 8 * 1024]; + let wire = encode("POST", "/echo", &body); + + let mut small = vec![0u8; 16]; + let DirectWriteResult::Overflow(required) = dispatch_into(wire.clone(), &mut small, &rt) else { + panic!("expected overflow"); + }; + + let mut exact = vec![0u8; required]; + let result = dispatch_into(wire.clone(), &mut exact, &rt); + assert_eq!(result, DirectWriteResult::Complete(required)); + assert_eq!(exact, dispatch_from_bytes(wire, &rt)); +} + +#[test] +fn body_without_content_type_matches_byte_path() { + // Regression for the Content-Type defaulting drift: dispatch_parts + // injects `content-type: application/json` for non-empty bodies + // without one; the direct-write path must do the same or JSON + // extractors behave differently across dispatch modes. + install(); + let rt = runtime(); + let header = json!({"v": 1, "method": "POST", "path": "/echo"}); // no headers at all + let header_bytes = serde_json::to_vec(&header).unwrap(); + let body = b"{\"k\":1}"; + let mut wire = u32::try_from(header_bytes.len()) + .unwrap() + .to_be_bytes() + .to_vec(); + wire.extend_from_slice(&header_bytes); + wire.extend_from_slice(body); + + let reference = dispatch_from_bytes(wire.clone(), &rt); + let mut out = vec![0u8; reference.len() + 64]; + let result = dispatch_into(wire, &mut out, &rt); + assert_eq!(result, DirectWriteResult::Complete(reference.len())); + assert_eq!( + &out[..reference.len()], + &reference[..], + "direct path must apply the same content-type defaulting as the byte path" + ); +} diff --git a/crates/vespera_inprocess/tests/request_size_cap.rs b/crates/vespera_inprocess/tests/request_size_cap.rs new file mode 100644 index 00000000..f8fa558f --- /dev/null +++ b/crates/vespera_inprocess/tests/request_size_cap.rs @@ -0,0 +1,68 @@ +//! Ingress request-size cap ([`vespera_inprocess::max_request_bytes`]). +//! +//! Runs in its own test binary so the process-global `OnceLock` cap is +//! isolated from the other integration tests (which assume the default +//! unlimited behaviour). Both tests pin the same cap so they are +//! order-independent under the parallel test runner. + +use serde_json::Value; +use tokio::runtime::Builder; +use vespera_inprocess::{dispatch_from_bytes, set_max_request_bytes}; + +/// Small enough that a tiny valid header passes but a padded request +/// trips the cap. +const CAP: usize = 100; + +fn ensure_cap() { + // First-wins `OnceLock`; every test sets the same value so whichever + // runs first, the effective cap is identical. + let _ = set_max_request_bytes(CAP); +} + +fn dispatch(wire: Vec) -> Value { + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("build runtime"); + let resp = dispatch_from_bytes(wire, &runtime); + assert!(resp.len() >= 4, "wire response too short"); + let header_len = u32::from_be_bytes(resp[..4].try_into().unwrap()) as usize; + serde_json::from_slice(&resp[4..4 + header_len]).expect("response header JSON") +} + +fn wire_with_body(body_len: usize) -> Vec { + let header = br#"{"v":1,"method":"GET","path":"/ping"}"#; + let mut wire = Vec::new(); + wire.extend_from_slice(&u32::try_from(header.len()).unwrap().to_be_bytes()); + wire.extend_from_slice(header); + wire.extend(std::iter::repeat_n(b'x', body_len)); + wire +} + +#[test] +fn oversized_request_returns_413() { + ensure_cap(); + let wire = wire_with_body(200); // total well over CAP + assert!(wire.len() > CAP); + let header = dispatch(wire); + assert_eq!( + header["status"].as_u64(), + Some(413), + "a request over the cap must be rejected with 413 before allocation" + ); +} + +#[test] +fn within_limit_request_is_not_capped() { + ensure_cap(); + let wire = wire_with_body(0); // small header-only request, under CAP + assert!(wire.len() <= CAP); + let header = dispatch(wire); + // No app is registered in this test binary, so a within-limit request + // falls through to the normal 404 (unknown app) — crucially NOT 413. + assert_ne!( + header["status"].as_u64(), + Some(413), + "a request within the cap must not be rejected as oversized" + ); +} diff --git a/crates/vespera_inprocess/tests/streaming_with_header.rs b/crates/vespera_inprocess/tests/streaming_with_header.rs index 98598beb..d88ebe15 100644 --- a/crates/vespera_inprocess/tests/streaming_with_header.rs +++ b/crates/vespera_inprocess/tests/streaming_with_header.rs @@ -20,8 +20,8 @@ use axum::routing::{get, post}; use bytes::Bytes; use serde_json::Value; use vespera_inprocess::{ - dispatch_bidirectional_streaming_with_header, dispatch_streaming_with_header_async, - register_app_named, + RequestChunk, dispatch_bidirectional_streaming_with_header, + dispatch_streaming_with_header_async, register_app_named, }; // ── Test app ───────────────────────────────────────────────────────── @@ -63,6 +63,13 @@ async fn discard_body() -> &'static str { "ok" } +/// Panics before producing any status/headers — exercises the +/// "handler panic before the header callback fires" path that the JNI +/// layer's `header_sent` fallback depends on. +async fn panic_before_header() -> Response { + panic!("intentional handler panic for test"); +} + fn make_router() -> Router { Router::new() .route("/ping", get(ping)) @@ -70,6 +77,7 @@ fn make_router() -> Router { .route("/triple", get(triple_header)) .route("/q", get(echo_query)) .route("/discard", post(discard_body)) + .route("/panic", get(panic_before_header)) } fn install_router() { @@ -345,7 +353,13 @@ async fn bidirectional_with_header_roundtrips_body() { let chunks = vec![b"foo".to_vec(), b"bar".to_vec()]; let chunks_iter = Mutex::new(chunks.into_iter()); - let pull = move || -> Option> { chunks_iter.lock().unwrap().next() }; + let pull = move || -> RequestChunk { + chunks_iter + .lock() + .unwrap() + .next() + .map_or(RequestChunk::End, RequestChunk::Data) + }; let header_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); let body_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); @@ -368,7 +382,7 @@ async fn bidirectional_with_header_roundtrips_body() { #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn bidirectional_with_header_error_on_short_input() { let bad: Vec = vec![0u8, 0, 0]; // < 4 bytes - let pull = || -> Option> { None }; + let pull = || -> RequestChunk { RequestChunk::End }; let header_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); let body_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); let h = Arc::clone(&header_buf); @@ -392,7 +406,7 @@ async fn bidirectional_with_header_error_on_short_input() { async fn bidirectional_with_header_error_on_version_mismatch() { install_router(); let bad = encode_bad_version("POST", "/echo"); - let pull = || -> Option> { None }; + let pull = || -> RequestChunk { RequestChunk::End }; let header_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); let body_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); let h = Arc::clone(&header_buf); @@ -415,7 +429,7 @@ async fn bidirectional_with_header_error_on_version_mismatch() { async fn bidirectional_with_header_error_on_unknown_app() { install_router(); let bad = encode_unknown_app("POST", "/echo"); - let pull = || -> Option> { None }; + let pull = || -> RequestChunk { RequestChunk::End }; let header_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); let body_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); let h = Arc::clone(&header_buf); @@ -438,7 +452,7 @@ async fn bidirectional_with_header_error_on_unknown_app() { async fn bidirectional_with_header_invalid_method_returns_405() { install_router(); let wire = encode_wire("BAD METHOD", "/echo", HashMap::new(), &[]); - let pull = || -> Option> { None }; + let pull = || -> RequestChunk { RequestChunk::End }; let header_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); let body_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); let h = Arc::clone(&header_buf); @@ -475,15 +489,15 @@ async fn bidirectional_with_header_break_when_receiver_dropped_mid_stream() { let counter = Arc::new(Mutex::new(0u32)); let counter_clone = Arc::clone(&counter); - let pull = move || -> Option> { + let pull = move || -> RequestChunk { let mut g = counter_clone.lock().unwrap(); if *g >= 1000 { - return None; + return RequestChunk::End; } *g += 1; // 4 KiB chunks — large enough that 16 slots ≈ 64 KiB worth // pile up before the handler decides to return. - Some(vec![0u8; 4096]) + RequestChunk::Data(vec![0u8; 4096]) }; let header_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); @@ -522,15 +536,15 @@ async fn bidirectional_with_header_slow_producer_yields_poll_pending() { let counter = Arc::new(Mutex::new(0u32)); let counter_clone = Arc::clone(&counter); - let pull = move || -> Option> { + let pull = move || -> RequestChunk { let mut g = counter_clone.lock().unwrap(); if *g >= 3 { - return None; + return RequestChunk::End; } *g += 1; // Sleep so the consumer drains the channel and hits Pending. std::thread::sleep(std::time::Duration::from_millis(25)); - Some(b"chunk".to_vec()) + RequestChunk::Data(b"chunk".to_vec()) }; let header_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); @@ -565,13 +579,13 @@ async fn bidirectional_with_header_empty_pull_chunks_are_skipped() { // Second call returns the real body, third returns None (EOF). let counter = Arc::new(Mutex::new(0u32)); let counter_clone = Arc::clone(&counter); - let pull = move || -> Option> { + let pull = move || -> RequestChunk { let mut g = counter_clone.lock().unwrap(); *g += 1; match *g { - 1 => Some(Vec::new()), // empty chunk — must be skipped - 2 => Some(b"X".to_vec()), - _ => None, + 1 => RequestChunk::Data(Vec::new()), // empty chunk — must be skipped + 2 => RequestChunk::Data(b"X".to_vec()), + _ => RequestChunk::End, } }; @@ -592,3 +606,41 @@ async fn bidirectional_with_header_empty_pull_chunks_are_skipped() { assert_eq!(header_json["status"].as_u64(), Some(200)); assert_eq!(body_buf.lock().unwrap().as_slice(), b"X"); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn streaming_with_header_handler_panic_does_not_emit_header() { + // Precondition lock for the JNI layer's `header_sent` fallback: when + // an axum handler panics BEFORE producing status/headers, the panic + // propagates through dispatch_streaming_with_header_async (the + // inprocess layer does NOT catch it) and `on_header` is never called. + // The JNI symbol relies on exactly this — its catch_unwind sees the + // panic with `header_sent == false` and emits a 500 header itself. + install_router(); + let wire = encode_wire("GET", "/panic", HashMap::new(), &[]); + + let header_seen = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let hs = Arc::clone(&header_seen); + + // Drive it on a spawned task so the handler panic surfaces as a + // JoinError instead of unwinding the test thread. + let join = tokio::spawn(async move { + dispatch_streaming_with_header_async( + wire, + move |_header: &[u8]| { + hs.store(true, std::sync::atomic::Ordering::SeqCst); + }, + |_chunk: &[u8]| {}, + ) + .await; + }) + .await; + + assert!( + join.is_err(), + "a handler panic must propagate (inprocess does not catch it)" + ); + assert!( + !header_seen.load(std::sync::atomic::Ordering::SeqCst), + "on_header must NOT fire when the handler panics before producing a header" + ); +} diff --git a/crates/vespera_inprocess/tests/wire_contract.rs b/crates/vespera_inprocess/tests/wire_contract.rs new file mode 100644 index 00000000..7ddbedfb --- /dev/null +++ b/crates/vespera_inprocess/tests/wire_contract.rs @@ -0,0 +1,191 @@ +//! **Wire-format contract locks** — byte-exact goldens for the +//! response wire header. +//! +//! These tests pin the serialized JSON *bytes* (field order, header +//! key order, `HeaderValue` untagged shape, metadata layout) so any +//! refactor of `collect_header_map` / wire serialization that changes +//! the observable wire format fails loudly. Do NOT update the +//! expected strings without an explicit wire-format review — Java +//! decoders and HMAC-style byte comparisons depend on this layout. + +use std::collections::HashMap; +use std::sync::Once; + +use axum::Router; +use axum::http::{HeaderMap, HeaderName}; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use serde_json::Value; +use tokio::runtime::Builder; +use vespera_inprocess::{dispatch_from_bytes, error_wire, register_app}; + +async fn contract_headers() -> Response { + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_static("x-single"), + "value-1".parse().unwrap(), + ); + let cookie = HeaderName::from_static("set-cookie"); + headers.append(cookie.clone(), "a=1".parse().unwrap()); + headers.append(cookie, "b=2".parse().unwrap()); + (headers, "ok").into_response() +} + +fn install_router() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + register_app(|| Router::new().route("/contract", get(contract_headers))); + }); +} + +fn encode_wire(method: &str, path: &str, headers: HashMap<&str, &str>, body: &[u8]) -> Vec { + let mut header = serde_json::Map::new(); + header.insert("v".to_owned(), Value::from(1u8)); + header.insert("method".to_owned(), Value::String(method.to_owned())); + header.insert("path".to_owned(), Value::String(path.to_owned())); + if !headers.is_empty() { + let headers_json: serde_json::Map = headers + .into_iter() + .map(|(k, v)| (k.to_owned(), Value::String(v.to_owned()))) + .collect(); + header.insert("headers".to_owned(), Value::Object(headers_json)); + } + let header_bytes = serde_json::to_vec(&Value::Object(header)).expect("header serialise"); + let header_len = u32::try_from(header_bytes.len()).expect("header fits u32"); + let mut wire = Vec::with_capacity(4 + header_bytes.len() + body.len()); + wire.extend_from_slice(&header_len.to_be_bytes()); + wire.extend_from_slice(&header_bytes); + wire.extend_from_slice(body); + wire +} + +fn split_wire(resp: &[u8]) -> (String, Vec) { + assert!(resp.len() >= 4, "wire response too short"); + let len_bytes: [u8; 4] = resp[..4].try_into().expect("4 bytes"); + let header_len = u32::from_be_bytes(len_bytes) as usize; + assert!( + 4 + header_len <= resp.len(), + "header_len overflows response" + ); + let header = String::from_utf8(resp[4..4 + header_len].to_vec()).expect("UTF-8 header"); + let body = resp[4 + header_len..].to_vec(); + (header, body) +} + +/// Golden: response wire header bytes for a multi-value-header +/// response. Locks: +/// - struct field order: `v`, `status`, `headers`, `metadata` +/// - BTreeMap alphabetical header key order +/// - `HeaderValue` untagged shape (string vs array) +/// - compact JSON (no whitespace) +#[test] +fn response_wire_header_bytes_are_locked() { + install_router(); + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + let resp = dispatch_from_bytes( + encode_wire("GET", "/contract", HashMap::new(), &[]), + &runtime, + ); + let (header, body) = split_wire(&resp); + assert_eq!(body, b"ok"); + + // wire-order locked — see module docs before changing. + let expected = format!( + concat!( + r#"{{"v":1,"status":200,"headers":{{"#, + r#""content-length":"2","#, + r#""content-type":"text/plain; charset=utf-8","#, + r#""set-cookie":["a=1","b=2"],"#, + r#""x-single":"value-1""#, + r#"}},"metadata":{{"version":"{version}"}}}}"# + ), + version = env!("CARGO_PKG_VERSION"), + ); + assert_eq!( + header, expected, + "wire response header bytes drifted — this is a WIRE FORMAT BREAK" + ); +} + +/// Golden: `error_wire` bytes. Locks the error path's exact shape — +/// content-type single value + plain-text body. +#[test] +fn error_wire_bytes_are_locked() { + let wire = error_wire(418, "teapot says no"); + let (header, body) = split_wire(&wire); + assert_eq!(body, b"teapot says no"); + + // wire-order locked — see module docs before changing. + let expected = format!( + concat!( + r#"{{"v":1,"status":418,"headers":{{"#, + r#""content-type":"text/plain; charset=utf-8""#, + r#"}},"metadata":{{"version":"{version}"}}}}"# + ), + version = env!("CARGO_PKG_VERSION"), + ); + assert_eq!( + header, expected, + "error_wire header bytes drifted — this is a WIRE FORMAT BREAK" + ); +} + +/// Golden: 422 hoisting shape — `validation_errors` appears as the +/// LAST field, after `metadata`, with `path`/`message` entry order. +#[test] +fn validation_hoist_wire_bytes_are_locked() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + vespera_inprocess::register_app_named("contract-422", || { + Router::new().route( + "/reject", + get(|| async { + ( + axum::http::StatusCode::UNPROCESSABLE_ENTITY, + [("content-type", "application/json")], + r#"{"errors":[{"path":"email","message":"not a valid email"}]}"#, + ) + }), + ) + }); + }); + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + let mut req_header = serde_json::Map::new(); + req_header.insert("v".to_owned(), Value::from(1u8)); + req_header.insert("method".to_owned(), Value::String("GET".to_owned())); + req_header.insert("path".to_owned(), Value::String("/reject".to_owned())); + req_header.insert("app".to_owned(), Value::String("contract-422".to_owned())); + let header_bytes = serde_json::to_vec(&Value::Object(req_header)).expect("serialise"); + let mut wire = Vec::with_capacity(4 + header_bytes.len()); + wire.extend_from_slice(&u32::try_from(header_bytes.len()).unwrap().to_be_bytes()); + wire.extend_from_slice(&header_bytes); + + let resp = dispatch_from_bytes(wire, &runtime); + let (header, body) = split_wire(&resp); + assert_eq!( + body, br#"{"errors":[{"path":"email","message":"not a valid email"}]}"#, + "original 422 body must be preserved verbatim" + ); + + // wire-order locked — see module docs before changing. + let expected = format!( + concat!( + r#"{{"v":1,"status":422,"headers":{{"#, + r#""content-length":"59","#, + r#""content-type":"application/json""#, + r#"}},"metadata":{{"version":"{version}"}},"#, + r#""validation_errors":[{{"path":"email","message":"not a valid email"}}]}}"# + ), + version = env!("CARGO_PKG_VERSION"), + ); + assert_eq!( + header, expected, + "422 hoisting wire bytes drifted — this is a WIRE FORMAT BREAK" + ); +} diff --git a/crates/vespera_inprocess/tests/wire_robustness.rs b/crates/vespera_inprocess/tests/wire_robustness.rs new file mode 100644 index 00000000..3d89e655 --- /dev/null +++ b/crates/vespera_inprocess/tests/wire_robustness.rs @@ -0,0 +1,160 @@ +//! Fuzz-style robustness harness for the wire trust boundary. +//! +//! Throws thousands of random, adversarial, and mutated byte sequences +//! at [`vespera_inprocess::dispatch_from_bytes`] and asserts the wire +//! contract on every one: +//! +//! * it **never panics** (no `unwrap`/index/slice/overflow reachable +//! from hostile input), and +//! * it **always returns a well-formed length-prefixed wire response** +//! (`[u32 BE header_len | JSON header]`) whose header is valid JSON +//! carrying a numeric `status`. +//! +//! This is a deterministic (seeded) `cargo test` complement to the +//! coverage-guided `cargo fuzz` target under `fuzz/` (which needs +//! nightly + libFuzzer and runs in CI/Linux). Any panic prints the +//! offending input prefix for replay. + +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use tokio::runtime::{Builder, Runtime}; +use vespera_inprocess::dispatch_from_bytes; + +/// Tiny deterministic xorshift PRNG — no dependency, exact replay. +struct XorShift(u64); + +impl XorShift { + fn next_u64(&mut self) -> u64 { + let mut x = self.0; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + self.0 = x; + x + } + + fn byte(&mut self) -> u8 { + (self.next_u64() & 0xff) as u8 + } + + /// Uniform in `[0, n)`; returns 0 when `n == 0`. + fn range(&mut self, n: usize) -> usize { + if n == 0 { + return 0; + } + // `v < n` (a `usize`), so it always fits back into `usize`. + usize::try_from(self.next_u64() % n as u64).unwrap_or(0) + } +} + +/// Dispatch `wire`, asserting no panic and a well-formed wire response. +fn assert_robust(rt: &Runtime, wire: &[u8]) { + let owned = wire.to_vec(); + let result = catch_unwind(AssertUnwindSafe(|| dispatch_from_bytes(owned, rt))); + + let Ok(resp) = result else { + let prefix = &wire[..wire.len().min(64)]; + panic!( + "dispatch_from_bytes PANICKED on input (len={}): {prefix:02x?}", + wire.len() + ); + }; + + assert!( + resp.len() >= 4, + "response shorter than the 4-byte length prefix ({} bytes)", + resp.len() + ); + let header_len = u32::from_be_bytes(resp[..4].try_into().unwrap()) as usize; + assert!( + 4 + header_len <= resp.len(), + "response header_len {header_len} overflows response ({} bytes)", + resp.len() + ); + let header: serde_json::Value = serde_json::from_slice(&resp[4..4 + header_len]) + .expect("response header must be valid JSON"); + assert!( + header + .get("status") + .and_then(serde_json::Value::as_u64) + .is_some(), + "response header must carry a numeric status: {header}" + ); +} + +fn runtime() -> Runtime { + Builder::new_current_thread() + .enable_all() + .build() + .expect("build current-thread runtime") +} + +#[test] +fn random_bytes_never_panic() { + let rt = runtime(); + let mut rng = XorShift(0x9E37_79B9_7F4A_7C15); + for _ in 0..5000 { + let len = rng.range(512); + let wire: Vec = (0..len).map(|_| rng.byte()).collect(); + assert_robust(&rt, &wire); + } +} + +#[test] +fn adversarial_header_len_never_panic() { + let rt = runtime(); + // 4-byte length prefixes claiming huge / edge `header_len` values with + // varying tails — exercises the bounds checks in `split_wire_request`. + for header_len in [ + 0u32, + 1, + 3, + 4, + 100, + 0x7fff_ffff, + 0x8000_0000, + 0xffff_fffe, + u32::MAX, + ] { + for tail in [0usize, 1, 4, 16, 64] { + let mut wire = header_len.to_be_bytes().to_vec(); + wire.extend(std::iter::repeat_n(b'{', tail)); + assert_robust(&rt, &wire); + } + } +} + +#[test] +fn structured_mutation_never_panic() { + let rt = runtime(); + // Start from a valid wire request and apply random byte mutations / + // truncations — keeps inputs near the parseable manifold so the + // deeper header-JSON / body-split paths are exercised, not just the + // early length-prefix rejects. + let base = { + let header = br#"{"v":1,"method":"POST","path":"/x","query":"a=1","headers":{"content-type":"application/json"},"app":"_default"}"#; + let mut wire = u32::try_from(header.len()).unwrap().to_be_bytes().to_vec(); + wire.extend_from_slice(header); + wire.extend_from_slice(b"{\"k\":\"v\"}"); + wire + }; + + let mut rng = XorShift(0xDEAD_BEEF_CAFE_BABE); + for _ in 0..3000 { + let mut wire = base.clone(); + let mutations = 1 + rng.range(4); + for _ in 0..mutations { + if wire.is_empty() { + break; + } + let idx = rng.range(wire.len()); + wire[idx] = rng.byte(); + } + // Occasionally truncate to exercise short/partial inputs. + if rng.range(3) == 0 && !wire.is_empty() { + let keep = rng.range(wire.len()); + wire.truncate(keep); + } + assert_robust(&rt, &wire); + } +} diff --git a/crates/vespera_jni/Cargo.toml b/crates/vespera_jni/Cargo.toml index 5190341c..50667678 100644 --- a/crates/vespera_jni/Cargo.toml +++ b/crates/vespera_jni/Cargo.toml @@ -10,6 +10,17 @@ repository.workspace = true vespera_inprocess = { workspace = true } jni = "0.22" tokio = { version = "1", features = ["rt-multi-thread"] } +# Optional high-performance global allocator for the final cdylib. +# Opt-in because #[global_allocator] is process-wide and must be the +# embedding crate's decision. +mimalloc = { version = "0.1", optional = true } + +[features] +# Use mimalloc as the global allocator inside the JNI cdylib. The +# default OS allocator (Windows HeapAlloc in particular) is measurably +# slower on the allocation-heavy dispatch paths (input Vec, response +# collect, wire response, streaming chunks). +mimalloc = ["dep:mimalloc"] [lints] workspace = true diff --git a/crates/vespera_jni/src/daemon_env.rs b/crates/vespera_jni/src/daemon_env.rs new file mode 100644 index 00000000..22b6d8fe --- /dev/null +++ b/crates/vespera_jni/src/daemon_env.rs @@ -0,0 +1,201 @@ +//! Thread-local cached daemon attachment to the JVM. +//! +//! Every JNI callback into the JVM needs a [`jni::Env`] valid for the +//! calling OS thread. Non-JVM threads (Tokio workers, `spawn_blocking` +//! pool threads) are not attached, so each callback would otherwise +//! `AttachCurrentThread` + detach — paying that cost **per call**. On +//! the streaming hot path that is once per body chunk (≈ 4096 times for +//! a 1 GiB / 256 KiB stream), and for async completion once per +//! dispatch. +//! +//! [`with_cached_daemon_env`] resolves the current thread's `JNIEnv` +//! **once** and caches it in thread-local storage; every subsequent +//! call on the same thread reuses it: +//! +//! * If the thread is **already attached** (e.g. a JVM-owned servlet +//! request thread driving `Runtime::block_on`), its env is *borrowed* +//! — never detached, because the JVM owns that attachment. +//! * Otherwise the thread is attached as a **daemon** +//! (`AttachCurrentThreadAsDaemon`, so it never blocks JVM shutdown) +//! and the attachment is *owned*: it is released with +//! `DetachCurrentThread` from the thread-local destructor when the OS +//! thread exits (e.g. a `spawn_blocking` worker reaped after its idle +//! timeout). Threads that outlive the process — the leaked static +//! runtime's workers — simply never run the destructor, which is +//! harmless at process teardown. +//! +//! # Safety invariant +//! +//! The cached `*mut jni::sys::JNIEnv` is valid **only on the exact OS +//! thread that produced it**. This is upheld structurally: +//! +//! * the pointer lives in a `thread_local!` cell, so it is never +//! observable from another thread; +//! * it is produced by `GetEnv` / `AttachCurrentThreadAsDaemon` *for +//! the current thread* and only ever dereferenced inside the same +//! [`with_cached_daemon_env`] call that read it back from TLS; +//! * `jni::Env` is `!Send`/`!Sync`, and the borrow handed to the +//! callback never escapes the closure; +//! * the owning [`CachedEnv`] stays in TLS for the thread's lifetime, +//! so the env stays attached for as long as the cached pointer is +//! reachable. +//! +//! A future polled across `.await` points may resume on a different +//! worker thread; that thread simply finds an empty TLS cell and +//! resolves its own env, so correctness does not depend on thread +//! affinity — only the amortised attach count does. + +use std::cell::RefCell; +use std::ffi::c_void; +use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind}; +use std::ptr; + +use jni::errors::jni_error_code_to_result; + +/// One thread's cached JVM attachment. Dropped from the thread-local +/// destructor on thread exit; detaches the JVM only for attachments +/// this module created (`owned`). +struct CachedEnv { + env_ptr: *mut jni::sys::JNIEnv, + jvm: jni::JavaVM, + owned: bool, +} + +impl Drop for CachedEnv { + fn drop(&mut self) { + if !self.owned { + // Borrowed a JVM-owned thread's env — the JVM owns the + // attachment lifecycle, we must not detach it. + return; + } + let raw_vm = self.jvm.get_raw(); + // SAFETY: `raw_vm` is a valid JavaVM pointer for this process. + // `DetachCurrentThread` runs on the exact OS thread whose daemon + // attachment we created in `resolve_current_env`, releasing the + // JVM's per-thread state as that thread exits. + unsafe { + ((*(*raw_vm)).v1_1.DetachCurrentThread)(raw_vm); + } + } +} + +thread_local! { + /// Cached attachment for the current OS thread (empty until the + /// first [`with_cached_daemon_env`] call resolves it). + static DAEMON_ENV: RefCell> = const { RefCell::new(None) }; +} + +/// Attach the current OS thread to the JVM as a daemon and return its +/// `JNIEnv`. +fn attach_daemon_thread(jvm: &jni::JavaVM) -> jni::errors::Result<*mut jni::sys::JNIEnv> { + let raw_vm = jvm.get_raw(); + let mut env_ptr = ptr::null_mut::(); + let mut args = jni::sys::JavaVMAttachArgs { + version: jni::JNIVersion::V1_4.into(), + name: ptr::null_mut(), + group: ptr::null_mut(), + }; + + // SAFETY: `raw_vm` comes from `Env::get_java_vm()` and is therefore a + // valid JavaVM pointer for this process. JNI 1.4 provides + // `AttachCurrentThreadAsDaemon`; the returned `JNIEnv` is valid only + // on the current OS thread and is cached in thread-local storage by + // the sole caller below. + let res = unsafe { + ((*(*raw_vm)).v1_4.AttachCurrentThreadAsDaemon)( + raw_vm, + &raw mut env_ptr, + (&raw mut args).cast::(), + ) + }; + jni_error_code_to_result(res)?; + if env_ptr.is_null() { + return Err(jni::errors::Error::NullPtr("AttachCurrentThreadAsDaemon")); + } + + Ok(env_ptr.cast()) +} + +/// Resolve the current thread's `JNIEnv`, returning `(env, owned)`. +/// +/// `owned == false` when the thread was **already** attached (the JVM +/// owns it — do not detach); `owned == true` when this call attached it +/// as a daemon (we detach on thread exit). +fn resolve_current_env(jvm: &jni::JavaVM) -> jni::errors::Result<(*mut jni::sys::JNIEnv, bool)> { + let raw_vm = jvm.get_raw(); + let mut env_ptr = ptr::null_mut::(); + let version: jni::sys::jint = jni::JNIVersion::V1_4.into(); + + // SAFETY: `raw_vm` is a valid JavaVM pointer. `GetEnv` reports + // whether the current thread is already attached without creating a + // new attachment. + let res = unsafe { ((*(*raw_vm)).v1_2.GetEnv)(raw_vm, &raw mut env_ptr, version) }; + if res == jni::sys::JNI_OK && !env_ptr.is_null() { + // Already attached (e.g. a JVM-owned request thread) — borrow it. + return Ok((env_ptr.cast(), false)); + } + + // Not attached (Tokio worker / spawn_blocking thread): attach as a + // daemon and take ownership of the attachment lifecycle. + let env_ptr = attach_daemon_thread(jvm)?; + Ok((env_ptr, true)) +} + +/// Run `callback` with a [`jni::Env`] for the current thread, resolving +/// (and caching) the attachment on first use and reusing it thereafter. +/// +/// The callback runs inside a fresh local-reference frame (so JNI local +/// refs created per call do not accumulate on the long-lived thread), +/// and any pending JVM exception is cleared afterwards — replacing the +/// scoped-detach cleanup that jni-rs runs for transient attachments but +/// cached attachments intentionally skip. +/// +/// Panics from `callback` are caught, the exception state is scrubbed, +/// and the panic is resumed so unwinding still cannot cross the FFI +/// boundary uncaught at the JNI entry point. +pub fn with_cached_daemon_env(jvm: &jni::JavaVM, callback: F) -> std::result::Result +where + F: FnOnce(&mut jni::Env<'_>) -> std::result::Result, + E: From, +{ + DAEMON_ENV.with(|cell| { + // Resolve + cache under a short-lived borrow, then release it + // before running the callback so a nested `with_cached_daemon_env` + // on the same thread cannot double-borrow the cell. + let env_ptr = { + let mut slot = cell.borrow_mut(); + if slot.is_none() { + let (env_ptr, owned) = resolve_current_env(jvm)?; + *slot = Some(CachedEnv { + env_ptr, + jvm: jvm.clone(), + owned, + }); + } + slot.as_ref() + .map(|cached| cached.env_ptr) + .expect("cache populated above") + }; + + // SAFETY: `env_ptr` was resolved for this exact OS thread (see + // the module-level safety invariant) and is confined to this + // thread's TLS cell; it is never shared across threads. The + // owning `CachedEnv` remains in TLS, so the attachment outlives + // this borrow. The per-call local frame prevents local-ref + // accumulation on the long-lived thread. + let mut guard = unsafe { jni::AttachGuard::from_unowned(env_ptr) }; + let env = guard.borrow_env_mut(); + let result = catch_unwind(AssertUnwindSafe(|| { + env.with_local_frame(jni::DEFAULT_LOCAL_FRAME_CAPACITY, callback) + })); + + if env.exception_check() { + env.exception_clear(); + } + + match result { + Ok(callback_result) => callback_result, + Err(payload) => resume_unwind(payload), + } + }) +} diff --git a/crates/vespera_jni/src/jni_impl.rs b/crates/vespera_jni/src/jni_impl.rs new file mode 100644 index 00000000..5af4cd4e --- /dev/null +++ b/crates/vespera_jni/src/jni_impl.rs @@ -0,0 +1,1001 @@ +use std::{cell::RefCell, future::Future, sync::LazyLock}; + +use jni::EnvUnowned; +use jni::errors::ThrowRuntimeExAndDefault; +use jni::objects::{Global, JByteArray, JByteBuffer, JClass, JObject}; +use jni::sys::{jbyteArray, jint}; + +use crate::daemon_env::with_cached_daemon_env; +use crate::streaming_closures::{ + call_header_consumer, complete_future, make_pull_closure, make_push_closure, +}; + +/// Multi-threaded Tokio runtime shared across all JNI calls. +/// +/// Worker thread count defaults to Tokio's heuristic (number of +/// logical CPUs) and can be capped for embeddings where the JVM's +/// own thread pools (e.g. Tomcat) compete for the same cores — +/// see [`runtime_worker_threads`]. +pub static RUNTIME: LazyLock = LazyLock::new(|| { + let mut builder = tokio::runtime::Builder::new_multi_thread(); + if let Some(workers) = runtime_worker_threads() { + builder.worker_threads(workers); + } + builder + .enable_all() + .build() + .expect("failed to create Tokio runtime") +}); + +const MIN_RUNTIME_WORKERS: usize = 1; +const MAX_RUNTIME_WORKERS: usize = 1024; + +static RUNTIME_WORKER_THREADS: std::sync::OnceLock> = std::sync::OnceLock::new(); + +thread_local! { + static SYNC_RUNTIME: tokio::runtime::Runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to create per-thread Tokio runtime"); + static STREAMING_PULL_BUFFER: RefCell> = const { RefCell::new(None) }; + static STREAMING_PUSH_BUFFER: RefCell> = const { RefCell::new(None) }; +} + +/// Drive a synchronous JNI dispatch on the calling OS thread's +/// current-thread Tokio runtime. +/// +/// The request future is driven to completion inside this `block_on`, +/// avoiding shared-runtime enter/scheduler contention on tiny +/// `dispatchBytes` / `dispatchDirect` calls. Handlers that await their +/// spawned tasks still complete normally, and `spawn_blocking` uses this +/// runtime's blocking pool. Detached `tokio::spawn` tasks are fragile on +/// this path: a current-thread runtime has no worker threads, so detached +/// tasks only make progress while a later `block_on` runs on the same +/// Java caller thread. The TLS runtime is dropped when that OS thread +/// exits, cleanly shutting down its per-runtime state. +fn block_on_sync_runtime(future: F) -> F::Output +where + F: Future, +{ + SYNC_RUNTIME.with(|runtime| runtime.block_on(future)) +} + +/// Build a `413` wire response when `len` exceeds the configured +/// request-size cap ([`vespera_inprocess::max_request_bytes`]); `None` +/// when within the limit (the default — unlimited). Lets the buffered +/// JNI entry points reject an oversized request **before** allocating +/// the Rust-side body copy that would otherwise double the Java +/// `byte[]` already resident. +fn oversized_request_wire(len: usize) -> Option> { + if vespera_inprocess::request_exceeds_limit(len) { + Some(vespera_inprocess::error_wire( + 413, + &format!( + "request size {len} bytes exceeds configured maximum of {} bytes", + vespera_inprocess::max_request_bytes() + ), + )) + } else { + None + } +} + +type StreamingChunkBuffer = Global>; + +#[derive(Clone, Copy)] +enum StreamingBufferRole { + Pull, + Push, +} + +impl StreamingBufferRole { + fn with_cache( + self, + callback: impl FnOnce(&RefCell>) -> R, + ) -> R { + match self { + Self::Pull => STREAMING_PULL_BUFFER.with(callback), + Self::Push => STREAMING_PUSH_BUFFER.with(callback), + } + } +} + +struct CachedStreamingChunkBuffer { + size: usize, + array: StreamingChunkBuffer, + checked_out: bool, +} + +// Released explicitly only after the streaming future returns normally. If a +// panic unwinds through a bidirectional dispatch while the request producer may +// still be in `InputStream.read`, the cache stays checked out and future +// dispatches allocate fresh buffers instead of aliasing the Java array. +struct StreamingChunkBufferLease { + role: StreamingBufferRole, +} + +impl StreamingChunkBufferLease { + const fn new(role: StreamingBufferRole) -> Self { + Self { role } + } + + fn mark_reusable(self) { + self.role.with_cache(|cache| { + if let Some(cached) = cache.borrow_mut().as_mut() { + cached.checked_out = false; + } + }); + } +} + +fn new_streaming_chunk_buffer( + env: &mut jni::Env<'_>, + size: usize, +) -> jni::errors::Result { + let local = env.new_byte_array(size)?; + env.new_global_ref(&local) +} + +fn checkout_streaming_chunk_buffer( + env: &mut jni::Env<'_>, + role: StreamingBufferRole, +) -> jni::errors::Result<(StreamingChunkBuffer, Option)> { + let size = streaming_chunk_size(); + role.with_cache(|cache| { + let mut slot = cache.borrow_mut(); + let replace_cached = slot + .as_ref() + .is_none_or(|cached| cached.size != size && !cached.checked_out); + + if replace_cached { + *slot = Some(CachedStreamingChunkBuffer { + size, + array: new_streaming_chunk_buffer(env, size)?, + checked_out: false, + }); + } + + let Some(cached) = slot.as_mut() else { + return Ok((new_streaming_chunk_buffer(env, size)?, None)); + }; + + if cached.size != size || cached.checked_out { + return Ok((new_streaming_chunk_buffer(env, size)?, None)); + } + + let cached_array: &JByteArray<'static> = cached.array.as_ref(); + let dispatch_array = env.new_global_ref(cached_array)?; + cached.checked_out = true; + Ok((dispatch_array, Some(StreamingChunkBufferLease::new(role)))) + }) +} + +fn mark_streaming_buffer_reusable(lease: Option) { + if let Some(lease) = lease { + lease.mark_reusable(); + } +} + +/// Worker thread count for the shared [`RUNTIME`], resolved once +/// (first hit wins, then fixed for the process lifetime): +/// +/// 1. [`set_runtime_worker_threads`] called before the runtime is +/// first used (the `configureRuntime0` JNI hook from +/// `VesperaBridge.init()` lands here) +/// 2. `VESPERA_RUNTIME_WORKERS` environment variable +/// 3. `None` — Tokio's default (number of logical CPUs) +/// +/// Values are clamped to `[1, 1024]`. +#[must_use] +pub fn runtime_worker_threads() -> Option { + *RUNTIME_WORKER_THREADS.get_or_init(|| { + std::env::var("VESPERA_RUNTIME_WORKERS") + .ok() + .and_then(|raw| raw.trim().parse::().ok()) + .map(|v| v.clamp(MIN_RUNTIME_WORKERS, MAX_RUNTIME_WORKERS)) + }) +} + +/// Override the shared runtime's worker thread count **before the +/// first dispatch**. Returns `false` when the value was already +/// fixed. Clamped to `[1, 1024]`. +pub fn set_runtime_worker_threads(workers: usize) -> bool { + RUNTIME_WORKER_THREADS + .set(Some( + workers.clamp(MIN_RUNTIME_WORKERS, MAX_RUNTIME_WORKERS), + )) + .is_ok() +} + +/// `com.devfive.vespera.bridge.VesperaBridge.configureRuntime0(int) -> void` +/// +/// Seeds the shared Tokio runtime's worker thread count **before +/// the first dispatch**. Values `<= 0` leave the setting +/// untouched (env var / Tokio default applies). Calls after the +/// configuration is fixed are silently ignored. +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_configureRuntime0<'local>( + _unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + worker_threads: jint, +) { + // Defensive `catch_unwind`: this body cannot panic today, but it is + // an `extern "system"` JNI symbol, so guard it for consistency with + // the dispatch symbols — an unwind must never cross the FFI boundary. + let _ = std::panic::catch_unwind(|| { + if let Ok(workers) = usize::try_from(worker_threads) + && workers > 0 + { + let _ = set_runtime_worker_threads(workers); + } + }); +} + +/// Per-chunk buffer size for streaming dispatches. +/// +/// Resolved once per process by +/// [`vespera_inprocess::streaming_chunk_bytes`] (default 256 KiB; +/// override via the `VESPERA_STREAMING_CHUNK_BYTES` env var or the +/// `configureStreaming0` JNI setter called from +/// `VesperaBridge.init()`). Large enough to amortise JNI call +/// overhead, small enough to keep memory bounded for multi-GB +/// streams. Subsequent calls are a single atomic load. +pub fn streaming_chunk_size() -> usize { + vespera_inprocess::streaming_chunk_bytes() +} + +/// `com.devfive.vespera.bridge.VesperaBridge.configureStreaming0(int, int) -> void` +/// +/// Seeds the process-wide streaming configuration **before the +/// first dispatch**. Values `<= 0` leave the corresponding +/// setting untouched (env var / default applies). Calls after +/// the configuration is fixed (first dispatch already ran, or a +/// previous call set it) are silently ignored — the JNI side has +/// no use for the failure signal beyond logging, which Java owns. +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_configureStreaming0<'local>( + _unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + chunk_bytes: jint, + channel_capacity: jint, +) { + // Defensive `catch_unwind` — see `configureRuntime0`: keep every JNI + // `extern "system"` symbol panic-safe even though this body cannot + // panic with the current setters. + let _ = std::panic::catch_unwind(|| { + if let Ok(bytes) = usize::try_from(chunk_bytes) + && bytes > 0 + { + let _ = vespera_inprocess::set_streaming_chunk_bytes(bytes); + } + if let Ok(slots) = usize::try_from(channel_capacity) + && slots > 0 + { + let _ = vespera_inprocess::set_streaming_channel_capacity(slots); + } + }); +} + +/// `com.devfive.vespera.bridge.VesperaBridge.dispatchBytes(byte[]) -> byte[]` +/// +/// **Synchronous** binary wire-format JNI entry point. Blocks the +/// calling thread until the Rust dispatch completes. Wraps the +/// entire pipeline in `catch_unwind` so a panic anywhere produces +/// a valid wire-format `500` response with a plain-text body — +/// JVM never sees an unwinding stack across the FFI boundary. +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchBytes<'local>( + mut unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + request_bytes: JByteArray<'local>, +) -> jbyteArray { + unowned_env + .with_env(|env| -> jni::errors::Result> { + let input = { + let len = request_bytes.len(env).unwrap_or(0); + // Ingress cap: reject an oversized request with 413 + // BEFORE allocating the Rust-side body copy (the + // amplification the Java `byte[]` would otherwise double). + if let Some(err) = oversized_request_wire(len) { + return Ok(env.byte_array_from_slice(&err)?.into()); + } + let mut buf = vec![0u8; len]; + // SAFETY: `u8` and `i8` (JNI's `jbyte`) have + // identical size/alignment; this views the + // freshly allocated buffer as the signed slice + // `get_region` expects. + let buf_i8 = + unsafe { std::slice::from_raw_parts_mut(buf.as_mut_ptr().cast::(), len) }; + if request_bytes.get_region(env, 0, buf_i8).is_err() { + let err = vespera_inprocess::error_wire( + 400, + "invalid input byte array (JNI conversion failed)", + ); + return Ok(env.byte_array_from_slice(&err)?.into()); + } + buf + }; + + let response = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + block_on_sync_runtime(vespera_inprocess::dispatch_from_bytes_async(input)) + })) + .unwrap_or_else(|_| vespera_inprocess::error_wire(500, "panic in Rust engine")); + + Ok(env.byte_array_from_slice(&response)?.into()) + }) + .resolve::() + .into_raw() +} + +/// Sentinel for [`Java_..._dispatchDirect`]: the response (or its +/// required size) cannot be represented in the `jint` return value +/// (> `i32::MAX` bytes). +/// +/// `jint::MIN` is the only value the `-(required_size)` protocol can +/// never produce: `required_size <= i32::MAX`, so the most negative +/// legitimate return is `-(i32::MAX) == jint::MIN + 1`. +const DIRECT_UNREPRESENTABLE: jint = jint::MIN; + +// Compile-time proof that the sentinel cannot collide with any +// legitimate `-(required_size)` value. +const _: () = assert!(DIRECT_UNREPRESENTABLE < -i32::MAX); + +/// Copy `response` into the caller's direct out buffer. +/// +/// Returns: +/// * `>= 0` — bytes written (`response` fit entirely) +/// * `< 0` — `-(required_size)`: nothing written, caller must retry +/// with a buffer of at least `required_size` bytes +/// * [`DIRECT_UNREPRESENTABLE`] — response exceeds `i32::MAX` bytes +/// and cannot be expressed in the return-code protocol +/// +/// # Safety contract (upheld by the caller) +/// +/// `out_addr` must point to a writable region of at least `out_cap` +/// bytes that stays valid for the duration of this call (a JNI +/// direct buffer pinned by the live `JByteBuffer` local ref). +fn write_response_to_out(out_addr: *mut u8, out_cap: usize, response: &[u8]) -> jint { + if response.len() <= out_cap { + // SAFETY: `response.len() <= out_cap` and the caller + // guarantees `out_addr..out_addr+out_cap` is writable. + // Source and destination cannot overlap: `response` is a + // Rust-owned Vec, the destination is a Java direct buffer. + unsafe { + std::ptr::copy_nonoverlapping(response.as_ptr(), out_addr, response.len()); + } + // Java buffer capacities are jint-bounded, so len <= cap + // always fits i32. + jint::try_from(response.len()).unwrap_or(DIRECT_UNREPRESENTABLE) + } else { + jint::try_from(response.len()).map_or(DIRECT_UNREPRESENTABLE, |required| -required) + } +} + +/// `com.devfive.vespera.bridge.VesperaBridge.dispatchDirect0(ByteBuffer, int, ByteBuffer) -> int` +/// (private native; the public Java wrapper `dispatchDirect` validates +/// buffer directness before crossing JNI) +/// +/// **Direct-buffer** synchronous dispatch — the zero-JNI-region-copy +/// sibling of [`Java_...dispatchBytes`]. +/// +/// Contract (mirrored in the Java wrapper's javadoc): +/// * `in_buf` / `out_buf` MUST be **direct** `ByteBuffer`s. The +/// Java wrapper enforces this before crossing JNI; non-direct +/// buffers reaching this symbol produce a thrown +/// `RuntimeException` (the jni crate surfaces a null direct +/// address as `Err`). +/// * The wire request is read from `in_buf[0..in_len]` — explicit +/// `in_len`, **never** the buffer's position/limit (eliminates +/// the classic "forgot to flip()" corruption). +/// * Return `>= 0`: a complete wire response was written to +/// `out_buf[0..n]`. +/// * Return `< 0`: `-(required_size)` — the response did not fit. +/// `out_buf` contents are **undefined** (a prefix may have been +/// written). `required_size` is exact, but retrying re-runs the +/// dispatch, so the Java side only auto-retries idempotent +/// methods. +/// * `Integer.MIN_VALUE`: response size exceeds `i32::MAX`. +/// +/// Compared with `dispatchBytes`, this path removes BOTH JNI +/// region copies (Java `byte[]` ↔ Rust), the per-call Java heap +/// array allocations, AND — via +/// [`vespera_inprocess::dispatch_into_async`] — the intermediate +/// response `Vec`: on the success path the wire header and each +/// body frame are written straight into `out_buf`. One plain +/// native memcpy remains on the request side (axum's `Body` +/// requires `'static` ownership), plus the per-frame copies of the +/// response body. `422` responses are materialised internally to +/// preserve `validation_errors` hoisting. +/// +/// # Safety invariants (comment-locked) +/// +/// 1. `in_buf` / `out_buf` stay rooted as live local refs for the +/// whole call — HotSpot neither moves nor frees the backing +/// memory of a direct buffer while its object is reachable. +/// 2. The raw addresses derived from them are used **only within +/// this function body** — never captured by closures, spawned +/// tasks, or returned structs. +/// 3. The input slice is copied into a Rust-owned `Vec` *before* +/// dispatch, so nothing borrowed from the buffer outlives the +/// read. +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchDirect0<'local>( + mut unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + in_buf: JByteBuffer<'local>, + in_len: jint, + out_buf: JByteBuffer<'local>, +) -> jint { + unowned_env + .with_env(|env| -> jni::errors::Result { + // Err here (null address ⇒ heap buffer, or JVM trouble) + // is thrown as RuntimeException via the resolve below — + // defense in depth behind the Java-side isDirect() check. + let in_addr = env.get_direct_buffer_address(&in_buf)?; + let in_cap = env.get_direct_buffer_capacity(&in_buf)?; + let out_addr = env.get_direct_buffer_address(&out_buf)?; + let out_cap = env.get_direct_buffer_capacity(&out_buf)?; + + // Validate in_len against the buffer's real capacity — + // all failures still produce a valid wire response in + // `out_buf`, per the dispatch* family contract. + let input = match usize::try_from(in_len) { + Ok(len) if len <= in_cap => { + // SAFETY: invariants 1–3 above; `len <= in_cap` + // bounds the read inside the direct buffer. + unsafe { std::slice::from_raw_parts(in_addr, len) }.to_vec() + } + _ => { + let err = vespera_inprocess::error_wire( + 400, + "invalid in_len (negative or exceeds buffer capacity)", + ); + return Ok(write_response_to_out(out_addr, out_cap, &err)); + } + }; + + let dispatched = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + // SAFETY: invariants 1–2 above — `out_addr` points + // to `out_cap` writable bytes of a direct buffer + // pinned by the live `out_buf` local ref; the Java + // caller is blocked for the whole call, so the + // region is exclusively ours; the slice never + // escapes this closure. + let out = unsafe { std::slice::from_raw_parts_mut(out_addr, out_cap) }; + block_on_sync_runtime(vespera_inprocess::dispatch_into_async(input, out)) + })); + + let code = match dispatched { + Ok(vespera_inprocess::DirectWriteResult::Complete(n)) => { + // n <= out_cap, and Java buffer capacities are + // jint-bounded, so this always fits i32. + jint::try_from(n).unwrap_or(DIRECT_UNREPRESENTABLE) + } + Ok(vespera_inprocess::DirectWriteResult::Overflow(required)) => { + jint::try_from(required).map_or(DIRECT_UNREPRESENTABLE, |r| -r) + } + Err(_) => { + let err = vespera_inprocess::error_wire(500, "panic in Rust engine"); + write_response_to_out(out_addr, out_cap, &err) + } + }; + Ok(code) + }) + .resolve::() +} + +/// `com.devfive.vespera.bridge.VesperaBridge.dispatchAsync(CompletableFuture, byte[]) -> void` +/// +/// **Asynchronous** binary wire-format JNI entry point. Returns +/// immediately after spawning the dispatch on the shared Tokio +/// runtime. Completes the supplied `CompletableFuture` +/// from a runtime worker thread once the response is ready. +/// +/// Contract (always-complete): +/// - **success** → `future.complete(responseBytes)` +/// - **JNI conversion failure** → `future.complete(error_wire(400, ...))` +/// - **Rust panic / handler crash** → `future.complete(error_wire(500, "panic in Rust engine"))` +/// The future is always completed with a valid wire response — +/// it is never left dangling, even on internal errors. +/// +/// Cancellation: Java's `future.cancel(true)` does NOT abort the +/// in-flight Rust task in this iteration (defer to follow-up). +/// Java callers may still observe cancellation via `future.isCancelled()`. +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchAsync<'local>( + mut unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + future_obj: JObject<'local>, + request_bytes: JByteArray<'local>, +) { + // The only unrecoverable path is failing to promote the future to a + // GlobalRef (below): without that ref there is nothing to complete, + // and a failure there means the JVM is already in trouble. Every + // path AFTER the ref exists completes the future, so the + // always-complete contract holds even on VM-promotion / scheduling + // failures. + let _ = unowned_env.with_env(|env| -> jni::errors::Result<()> { + let future_global: Global> = env.new_global_ref(&future_obj)?; + + let input = { + let len = request_bytes.len(env).unwrap_or(0); + // Ingress cap: complete the future with 413 BEFORE allocating + // the Rust-side body copy if the request exceeds the limit. + if let Some(err) = oversized_request_wire(len) { + let _ = complete_future(env, &future_global, &err); + return Ok(()); + } + let mut buf = vec![0u8; len]; + // SAFETY: `u8` and `i8` (JNI's `jbyte`) have + // identical size/alignment; this views the + // freshly allocated buffer as the signed slice + // `get_region` expects. + let buf_i8 = + unsafe { std::slice::from_raw_parts_mut(buf.as_mut_ptr().cast::(), len) }; + if request_bytes.get_region(env, 0, buf_i8).is_err() { + let err = vespera_inprocess::error_wire( + 400, + "invalid input byte array (JNI conversion failed)", + ); + let _ = complete_future(env, &future_global, &err); + return Ok(()); + } + buf + }; + + // Promote the VM; on the (near-impossible) failure complete the + // future we already hold so it never dangles. + let jvm = match env.get_java_vm() { + Ok(jvm) => jvm, + Err(e) => { + let _ = complete_future( + env, + &future_global, + &vespera_inprocess::error_wire(500, "JNI VM promotion failed"), + ); + return Err(e); + } + }; + + // A second owning global ref for the spawned task (`Global` is + // not `Clone`); the original `future_global` stays on this thread + // to complete the future if scheduling fails below. Both refs + // are independent GC roots to the same Java future. + let future_for_task = match env.new_global_ref(&future_obj) { + Ok(g) => g, + Err(e) => { + let _ = complete_future( + env, + &future_global, + &vespera_inprocess::error_wire(500, "JNI global ref failed"), + ); + return Err(e); + } + }; + + // The inner task converts Rust panics into JoinError, preserving + // always-complete semantics for the Java future. Scheduling + // itself is wrapped in `catch_unwind` so a failure to build or + // schedule on the shared runtime completes the future (with a + // 500) instead of leaving the Java caller hanging. + let scheduled = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + RUNTIME.spawn(async move { + let response = tokio::spawn(vespera_inprocess::dispatch_from_bytes_async(input)) + .await + .unwrap_or_else(|_| vespera_inprocess::error_wire(500, "panic in Rust engine")); + + let _ = with_cached_daemon_env(&jvm, |env| -> jni::errors::Result<()> { + complete_future(env, &future_for_task, &response) + }); + }); + })); + if scheduled.is_err() { + let _ = complete_future( + env, + &future_global, + &vespera_inprocess::error_wire(500, "failed to schedule Rust dispatch"), + ); + } + + Ok(()) + }); +} + +/// `com.devfive.vespera.bridge.VesperaBridge.dispatchStreaming(byte[], OutputStream) -> byte[]` +/// +/// **Streaming** JNI entry point. Drives the dispatch +/// synchronously like [`Java_...dispatchBytes`], but emits the +/// response body chunk-by-chunk by calling `outputStream.write(byte[])` +/// for each chunk axum produces — no full-body materialisation on +/// either the Rust or JVM side. +/// +/// Returns the wire-format **header only** (`[u32 BE header_len | +/// header JSON]`) — the body is delivered through the +/// `OutputStream` argument while the dispatch is in flight. +/// Callers (e.g. Spring `StreamingResponseBody`) read the header +/// first to commit the HTTP status + response headers, then +/// continue serving the streamed body bytes. +/// +/// Failure modes mirror [`Java_...dispatchBytes`]: malformed wire, +/// version mismatch, no app registered, or Rust panic produce a +/// regular `error_wire(...)` response (header + small body) and +/// the `OutputStream` is **not** written to. +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchStreaming<'local>( + mut unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + request_bytes: JByteArray<'local>, + output_stream: JObject<'local>, +) -> jbyteArray { + unowned_env + .with_env(|env| -> jni::errors::Result> { + let Ok(input) = env.convert_byte_array(&request_bytes) else { + let err = vespera_inprocess::error_wire( + 400, + "invalid input byte array (JNI conversion failed)", + ); + return Ok(env.byte_array_from_slice(&err)?.into()); + }; + + // Promote the OutputStream to Global so we can call + // .write() from a different attached thread inside + // the streaming callback. + let stream_global: Global> = env.new_global_ref(&output_stream)?; + let jvm = env.get_java_vm()?; + + // One per-thread reusable Java chunk buffer for the whole stream. + let (push_buf, push_buf_lease) = + checkout_streaming_chunk_buffer(env, StreamingBufferRole::Push)?; + + let header_bytes = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + RUNTIME.block_on(vespera_inprocess::dispatch_streaming_async( + input, + make_push_closure(jvm, stream_global, push_buf), + )) + })); + let header_bytes = header_bytes.map_or_else( + |_| vespera_inprocess::error_wire(500, "panic in Rust engine"), + |header_bytes| { + mark_streaming_buffer_reusable(push_buf_lease); + header_bytes + }, + ); + + Ok(env.byte_array_from_slice(&header_bytes)?.into()) + }) + .resolve::() + .into_raw() +} + +/// `com.devfive.vespera.bridge.VesperaBridge.dispatchFullStreaming(byte[], InputStream, OutputStream) -> byte[]` +/// +/// **Bidirectional streaming** JNI entry point. Reads the request +/// body chunk-by-chunk from `inputStream.read(byte[])` and emits +/// response body chunks via `outputStream.write(byte[])` — neither +/// side ever materialises the full body in memory, so 1 GiB +/// uploads with 1 GiB downloads run in O(chunk_size) RAM. +/// +/// Returns the wire-format **header only** (`[u32 BE header_len | +/// header JSON]`); the response body was delivered through +/// `outputStream`. +/// +/// Wire envelope contract: +/// - `headerBytes` is a wire-format request **without a body** +/// (just the 4-byte length prefix + JSON header). Send the +/// request body via `inputStream`, not embedded in this buffer. +/// - `inputStream.read(byte[])` semantics: returns `-1` on EOF, +/// `0` for an empty read (will be retried), or `>0` for the +/// number of bytes read into the supplied buffer. +/// +/// Failure modes mirror [`Java_...dispatchStreaming`]: malformed +/// wire / unknown version / no app / Rust panic produce a normal +/// `error_wire(...)` response in the returned bytes and neither +/// stream is touched. +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchFullStreaming< + 'local, +>( + mut unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + header_bytes: JByteArray<'local>, + input_stream: JObject<'local>, + output_stream: JObject<'local>, +) -> jbyteArray { + unowned_env + .with_env(|env| -> jni::errors::Result> { + let Ok(header_input) = env.convert_byte_array(&header_bytes) else { + let err = vespera_inprocess::error_wire( + 400, + "invalid header byte array (JNI conversion failed)", + ); + return Ok(env.byte_array_from_slice(&err)?.into()); + }; + + let input_global: Global> = env.new_global_ref(&input_stream)?; + let output_global: Global> = env.new_global_ref(&output_stream)?; + let jvm = env.get_java_vm()?; + + // Pull and push run concurrently on different threads, so each + // direction checks out its own per-thread cached buffer. + let (pull_buf, pull_buf_lease) = + checkout_streaming_chunk_buffer(env, StreamingBufferRole::Pull)?; + let (push_buf, push_buf_lease) = + match checkout_streaming_chunk_buffer(env, StreamingBufferRole::Push) { + Ok(checked_out) => checked_out, + Err(err) => { + mark_streaming_buffer_reusable(pull_buf_lease); + return Err(err); + } + }; + + // Closures capture clones of the JavaVM and Globals; + // both types are Send+Sync. + let pull_jvm = jvm.clone(); + let pull_global = input_global; + let push_jvm = jvm; + let push_global = output_global; + + let header_response = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + RUNTIME.block_on(vespera_inprocess::dispatch_bidirectional_streaming( + header_input, + // Pull request body chunks from Java InputStream. + // Runs on a tokio blocking thread (spawn_blocking + // inside dispatch_bidirectional_streaming). + make_pull_closure(pull_jvm, pull_global, pull_buf), + // Push response body chunks to Java OutputStream. + // Runs on the tokio worker driving the dispatch. + make_push_closure(push_jvm, push_global, push_buf), + )) + })); + let header_response = header_response.map_or_else( + |_| vespera_inprocess::error_wire(500, "panic in Rust engine"), + |header_response| { + mark_streaming_buffer_reusable(pull_buf_lease); + mark_streaming_buffer_reusable(push_buf_lease); + header_response + }, + ); + + Ok(env.byte_array_from_slice(&header_response)?.into()) + }) + .resolve::() + .into_raw() +} + +/// `com.devfive.vespera.bridge.VesperaBridge.dispatchStreamingWithHeader(byte[], Consumer, OutputStream) -> void` +/// +/// Same as [`Java_...dispatchStreaming`] but emits the wire-format +/// response header via `headerConsumer.accept(byte[])` **before** +/// the first body byte reaches `outputStream`. This lets +/// Spring-style `HttpServletResponse` controllers commit status +/// and headers while the response is still uncommitted. +/// +/// `headerConsumer` is invoked exactly once on every code path +/// (success or error); the bytes are a normal wire-format header +/// (length-prefixed JSON). On error `outputStream` is not +/// touched. +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchStreamingWithHeader< + 'local, +>( + mut unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + request_bytes: JByteArray<'local>, + header_consumer: JObject<'local>, + output_stream: JObject<'local>, +) { + let _ = unowned_env.with_env(|env| -> jni::errors::Result<()> { + let Ok(input) = env.convert_byte_array(&request_bytes) else { + let err = vespera_inprocess::error_wire( + 400, + "invalid input byte array (JNI conversion failed)", + ); + let _ = call_header_consumer(env, &env.new_global_ref(&header_consumer)?, &err); + return Ok(()); + }; + + let header_global: Global> = env.new_global_ref(&header_consumer)?; + let stream_global: Global> = env.new_global_ref(&output_stream)?; + let jvm = env.get_java_vm()?; + + // One per-thread reusable Java chunk buffer for the whole stream. + let (push_buf, push_buf_lease) = + checkout_streaming_chunk_buffer(env, StreamingBufferRole::Push)?; + + // Panic safety: catch_unwind absorbs Rust panics so the JVM + // never sees an unwinding stack across the FFI boundary. + // `header_sent` records whether the header callback fired; if a + // panic unwinds BEFORE it does (e.g. the axum handler panicked + // inside dispatch, before status/headers are produced), we fire + // the consumer once with a 500 header below so the documented + // "header consumer invoked exactly once on every code path" + // contract holds and the Java caller is not left hanging. A + // panic AFTER the header fired leaves Spring's response partially + // committed — unrecoverable, but the contract is already met. + let header_sent = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let header_sent_cb = std::sync::Arc::clone(&header_sent); + let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let header_for_cb = header_global; + let jvm_for_cb = jvm.clone(); + let push = make_push_closure(jvm, stream_global, push_buf); + RUNTIME.block_on(vespera_inprocess::dispatch_streaming_with_header_async( + input, + |header_bytes: &[u8]| { + header_sent_cb.store(true, std::sync::atomic::Ordering::SeqCst); + let _ = with_cached_daemon_env( + &jvm_for_cb, + |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { + call_header_consumer(env, &header_for_cb, header_bytes) + }, + ); + }, + push, + )); + })); + if panic_result.is_ok() { + mark_streaming_buffer_reusable(push_buf_lease); + } else if !header_sent.load(std::sync::atomic::Ordering::SeqCst) + && let Ok(fallback) = env.new_global_ref(&header_consumer) + { + let err = vespera_inprocess::error_wire(500, "panic in Rust engine"); + let _ = call_header_consumer(env, &fallback, &err); + } + + Ok(()) + }); +} + +/// `com.devfive.vespera.bridge.VesperaBridge.dispatchFullStreamingWithHeader(byte[], Consumer, InputStream, OutputStream) -> void` +/// +/// Bidirectional streaming with the same header-callback contract +/// as [`Java_...dispatchStreamingWithHeader`]. Request body +/// pulled from `inputStream`, response header emitted via +/// `headerConsumer.accept(byte[])` once axum produces status + +/// headers, then response body chunks streamed to `outputStream`. +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchFullStreamingWithHeader< + 'local, +>( + mut unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + header_bytes_in: JByteArray<'local>, + header_consumer: JObject<'local>, + input_stream: JObject<'local>, + output_stream: JObject<'local>, +) { + let _ = unowned_env.with_env(|env| -> jni::errors::Result<()> { + let Ok(header_input) = env.convert_byte_array(&header_bytes_in) else { + let err = vespera_inprocess::error_wire( + 400, + "invalid header byte array (JNI conversion failed)", + ); + let _ = call_header_consumer(env, &env.new_global_ref(&header_consumer)?, &err); + return Ok(()); + }; + + let header_global: Global> = env.new_global_ref(&header_consumer)?; + let input_global: Global> = env.new_global_ref(&input_stream)?; + let output_global: Global> = env.new_global_ref(&output_stream)?; + let jvm = env.get_java_vm()?; + + // Pull and push run concurrently on different threads. + let (pull_buf, pull_buf_lease) = + checkout_streaming_chunk_buffer(env, StreamingBufferRole::Pull)?; + let (push_buf, push_buf_lease) = + match checkout_streaming_chunk_buffer(env, StreamingBufferRole::Push) { + Ok(checked_out) => checked_out, + Err(err) => { + mark_streaming_buffer_reusable(pull_buf_lease); + return Err(err); + } + }; + + let pull_jvm = jvm.clone(); + let pull_global = input_global; + let push_jvm = jvm.clone(); + let push_global = output_global; + let header_jvm = jvm; + let header_for_cb = header_global; + + // See dispatchStreamingWithHeader: `header_sent` lets us honour + // the "header consumer invoked exactly once on every code path" + // contract — if a panic unwinds before the header callback fires + // (e.g. the handler panicked before producing status/headers), + // we fire the consumer once with a 500 below instead of leaving + // the Java caller hanging. + let header_sent = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let header_sent_cb = std::sync::Arc::clone(&header_sent); + let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + RUNTIME.block_on( + vespera_inprocess::dispatch_bidirectional_streaming_with_header( + header_input, + make_pull_closure(pull_jvm, pull_global, pull_buf), + make_push_closure(push_jvm, push_global, push_buf), + |header_bytes: &[u8]| { + header_sent_cb.store(true, std::sync::atomic::Ordering::SeqCst); + let _ = with_cached_daemon_env( + &header_jvm, + |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { + call_header_consumer(env, &header_for_cb, header_bytes) + }, + ); + }, + ), + ); + })); + if panic_result.is_ok() { + mark_streaming_buffer_reusable(pull_buf_lease); + mark_streaming_buffer_reusable(push_buf_lease); + } else if !header_sent.load(std::sync::atomic::Ordering::SeqCst) + && let Ok(fallback) = env.new_global_ref(&header_consumer) + { + let err = vespera_inprocess::error_wire(500, "panic in Rust engine"); + let _ = call_header_consumer(env, &fallback, &err); + } + + Ok(()) + }); +} + +#[cfg(test)] +mod runtime_config_tests { + use super::{runtime_worker_threads, set_runtime_worker_threads}; + + /// One test owns the process-global `OnceLock`: setter wins, + /// clamping applies, and later writes are rejected. + #[test] + fn setter_fixes_clamped_value_first_wins() { + assert!(set_runtime_worker_threads(99_999), "first set must win"); + assert_eq!( + runtime_worker_threads(), + Some(1024), + "value must clamp to the upper bound" + ); + assert!( + !set_runtime_worker_threads(4), + "second set must be rejected once fixed" + ); + assert_eq!(runtime_worker_threads(), Some(1024)); + } +} + +#[cfg(test)] +mod direct_tests { + use super::write_response_to_out; + + #[test] + fn response_fits_returns_len_and_writes_bytes() { + let mut out = vec![0u8; 16]; + let response = b"hello wire"; + let n = write_response_to_out(out.as_mut_ptr(), out.len(), response); + assert_eq!(n, 10); + assert_eq!(&out[..10], response); + } + + #[test] + fn exact_fit_boundary() { + let mut out = vec![0u8; 4]; + let n = write_response_to_out(out.as_mut_ptr(), out.len(), b"abcd"); + assert_eq!(n, 4); + assert_eq!(&out[..], b"abcd"); + } + + #[test] + fn overflow_returns_negative_required_size_and_writes_nothing() { + let mut out = vec![0xAAu8; 4]; + let n = write_response_to_out(out.as_mut_ptr(), out.len(), b"too large"); + assert_eq!(n, -9); + assert_eq!( + &out[..], + &[0xAA; 4], + "overflow must not touch the out buffer" + ); + } + + #[test] + fn zero_capacity_overflow() { + let mut out: Vec = Vec::new(); + let n = write_response_to_out(out.as_mut_ptr(), 0, b"x"); + assert_eq!(n, -1); + } +} diff --git a/crates/vespera_jni/src/lib.rs b/crates/vespera_jni/src/lib.rs index f5f95de2..12cb90a3 100644 --- a/crates/vespera_jni/src/lib.rs +++ b/crates/vespera_jni/src/lib.rs @@ -17,6 +17,18 @@ pub use jni; pub use vespera_inprocess; +/// mimalloc as the process-wide allocator (feature `mimalloc`). +/// +/// The JNI dispatch hot path allocates several times per call (input +/// buffer, request body, response collection, wire response); the OS +/// default allocator — Windows `HeapAlloc` in particular — is +/// measurably slower than mimalloc on this pattern. Opt-in because a +/// `#[global_allocator]` is process-wide and belongs to the final +/// cdylib's build decision. +#[cfg(feature = "mimalloc")] +#[global_allocator] +static GLOBAL_ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc; + /// Generate the `JNI_OnLoad` export that registers a single (default) /// app. Backward-compatible sugar for the single-app case; new code /// targeting multiple apps should use [`jni_apps!`] directly. @@ -94,570 +106,8 @@ macro_rules! jni_apps { // Everything below requires a JVM — excluded from coverage. #[cfg(not(tarpaulin_include))] -mod jni_impl { - use std::sync::LazyLock; - - use jni::EnvUnowned; - use jni::errors::ThrowRuntimeExAndDefault; - use jni::objects::{Global, JByteArray, JClass, JObject, JValue}; - use jni::sys::jbyteArray; - use jni::{jni_sig, jni_str}; - - /// Multi-threaded Tokio runtime shared across all JNI calls. - pub static RUNTIME: LazyLock = LazyLock::new(|| { - tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .expect("failed to create Tokio runtime") - }); - - /// Per-chunk buffer size for streaming dispatches (16 KiB — large - /// enough to amortise JNI call overhead, small enough to keep - /// memory bounded for multi-GB streams). - const STREAMING_CHUNK_SIZE: usize = 16 * 1024; - - /// `com.devfive.vespera.bridge.VesperaBridge.dispatchBytes(byte[]) -> byte[]` - /// - /// **Synchronous** binary wire-format JNI entry point. Blocks the - /// calling thread until the Rust dispatch completes. Wraps the - /// entire pipeline in `catch_unwind` so a panic anywhere produces - /// a valid wire-format `500` response with a plain-text body — - /// JVM never sees an unwinding stack across the FFI boundary. - #[unsafe(no_mangle)] - pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchBytes<'local>( - mut unowned_env: EnvUnowned<'local>, - _class: JClass<'local>, - request_bytes: JByteArray<'local>, - ) -> jbyteArray { - unowned_env - .with_env(|env| -> jni::errors::Result> { - let Ok(input) = env.convert_byte_array(&request_bytes) else { - let err = vespera_inprocess::error_wire( - 400, - "invalid input byte array (JNI conversion failed)", - ); - return Ok(env.byte_array_from_slice(&err)?.into()); - }; - - let response = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - vespera_inprocess::dispatch_from_bytes(input, &RUNTIME) - })) - .unwrap_or_else(|_| vespera_inprocess::error_wire(500, "panic in Rust engine")); - - Ok(env.byte_array_from_slice(&response)?.into()) - }) - .resolve::() - .into_raw() - } - - /// `com.devfive.vespera.bridge.VesperaBridge.dispatchAsync(CompletableFuture, byte[]) -> void` - /// - /// **Asynchronous** binary wire-format JNI entry point. Returns - /// immediately after spawning the dispatch on the shared Tokio - /// runtime. Completes the supplied `CompletableFuture` - /// from a runtime worker thread once the response is ready. - /// - /// Contract (always-complete): - /// - **success** → `future.complete(responseBytes)` - /// - **JNI conversion failure** → `future.complete(error_wire(400, ...))` - /// - **Rust panic / handler crash** → `future.complete(error_wire(500, "panic in Rust engine"))` - /// The future is always completed with a valid wire response — - /// it is never left dangling, even on internal errors. - /// - /// Cancellation: Java's `future.cancel(true)` does NOT abort the - /// in-flight Rust task in this iteration (defer to follow-up). - /// Java callers may still observe cancellation via `future.isCancelled()`. - #[unsafe(no_mangle)] - pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchAsync<'local>( - mut unowned_env: EnvUnowned<'local>, - _class: JClass<'local>, - future_obj: JObject<'local>, - request_bytes: JByteArray<'local>, - ) { - // Best-effort: any error inside with_env aborts the dispatch - // (future will dangle on the Java side — only happens if we - // can't even promote the future to a GlobalRef, which would - // mean the JVM is already in trouble). - let _ = unowned_env.with_env(|env| -> jni::errors::Result<()> { - // 1. Promote CompletableFuture to Global so it survives - // across the tokio task boundary. - let future_global: Global> = env.new_global_ref(&future_obj)?; - - // 2. Try to convert the input byte array. On failure, - // complete the future synchronously with the error wire - // and return early — no async work needed. - let Ok(input) = env.convert_byte_array(&request_bytes) else { - let err = vespera_inprocess::error_wire( - 400, - "invalid input byte array (JNI conversion failed)", - ); - let _ = complete_future(env, &future_global, &err); - return Ok(()); - }; - - // 3. Snapshot the JavaVM (Send + Sync) so we can re-attach - // the tokio worker thread once the dispatch completes. - let jvm = env.get_java_vm()?; - - // 4. Fire-and-forget on the runtime. An inner tokio::spawn - // converts any panic in dispatch_from_bytes_async into - // a JoinError, guaranteeing always-complete semantics. - RUNTIME.spawn(async move { - let response = tokio::spawn(vespera_inprocess::dispatch_from_bytes_async(input)) - .await - .unwrap_or_else(|_| vespera_inprocess::error_wire(500, "panic in Rust engine")); - - // Re-attach to JVM on this worker thread; subsequent - // dispatches on the same thread will hit the TLS fast - // path (cheap). - let _ = jvm.attach_current_thread(|env| -> jni::errors::Result<()> { - complete_future(env, &future_global, &response) - }); - }); - - Ok(()) - }); - } - - /// `com.devfive.vespera.bridge.VesperaBridge.dispatchStreaming(byte[], OutputStream) -> byte[]` - /// - /// **Streaming** JNI entry point. Drives the dispatch - /// synchronously like [`Java_...dispatchBytes`], but emits the - /// response body chunk-by-chunk by calling `outputStream.write(byte[])` - /// for each chunk axum produces — no full-body materialisation on - /// either the Rust or JVM side. - /// - /// Returns the wire-format **header only** (`[u32 BE header_len | - /// header JSON]`) — the body is delivered through the - /// `OutputStream` argument while the dispatch is in flight. - /// Callers (e.g. Spring `StreamingResponseBody`) read the header - /// first to commit the HTTP status + response headers, then - /// continue serving the streamed body bytes. - /// - /// Failure modes mirror [`Java_...dispatchBytes`]: malformed wire, - /// version mismatch, no app registered, or Rust panic produce a - /// regular `error_wire(...)` response (header + small body) and - /// the `OutputStream` is **not** written to. - #[unsafe(no_mangle)] - pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchStreaming< - 'local, - >( - mut unowned_env: EnvUnowned<'local>, - _class: JClass<'local>, - request_bytes: JByteArray<'local>, - output_stream: JObject<'local>, - ) -> jbyteArray { - unowned_env - .with_env(|env| -> jni::errors::Result> { - let Ok(input) = env.convert_byte_array(&request_bytes) else { - let err = vespera_inprocess::error_wire( - 400, - "invalid input byte array (JNI conversion failed)", - ); - return Ok(env.byte_array_from_slice(&err)?.into()); - }; - - // Promote the OutputStream to Global so we can call - // .write() from a different attached thread inside - // the streaming callback. - let stream_global: Global> = env.new_global_ref(&output_stream)?; - let jvm = env.get_java_vm()?; - - let header_bytes = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - RUNTIME.block_on(vespera_inprocess::dispatch_streaming_async( - input, - |chunk: &[u8]| { - // Per-chunk: attach (cheap on subsequent - // calls — TLS fast path) + push a local - // frame to keep the local-ref table bounded - // even for streams with thousands of chunks. - let _ = jvm.attach_current_thread( - |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { - env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { - let arr = env.byte_array_from_slice(chunk)?; - let arr_obj: JObject = arr.into(); - env.call_method( - &stream_global, - jni_str!("write"), - jni_sig!("([B)V"), - &[JValue::Object(&arr_obj)], - )?; - // Any IOException thrown by write() is left - // pending on the env; clear it so subsequent - // chunks on the same thread aren't poisoned. - if env.exception_check() { - env.exception_clear(); - } - Ok(()) - }) - }, - ); - }, - )) - })) - .unwrap_or_else(|_| vespera_inprocess::error_wire(500, "panic in Rust engine")); - - Ok(env.byte_array_from_slice(&header_bytes)?.into()) - }) - .resolve::() - .into_raw() - } - - /// `com.devfive.vespera.bridge.VesperaBridge.dispatchFullStreaming(byte[], InputStream, OutputStream) -> byte[]` - /// - /// **Bidirectional streaming** JNI entry point. Reads the request - /// body chunk-by-chunk from `inputStream.read(byte[])` and emits - /// response body chunks via `outputStream.write(byte[])` — neither - /// side ever materialises the full body in memory, so 1 GiB - /// uploads with 1 GiB downloads run in O(chunk_size) RAM. - /// - /// Returns the wire-format **header only** (`[u32 BE header_len | - /// header JSON]`); the response body was delivered through - /// `outputStream`. - /// - /// Wire envelope contract: - /// - `headerBytes` is a wire-format request **without a body** - /// (just the 4-byte length prefix + JSON header). Send the - /// request body via `inputStream`, not embedded in this buffer. - /// - `inputStream.read(byte[])` semantics: returns `-1` on EOF, - /// `0` for an empty read (will be retried), or `>0` for the - /// number of bytes read into the supplied buffer. - /// - /// Failure modes mirror [`Java_...dispatchStreaming`]: malformed - /// wire / unknown version / no app / Rust panic produce a normal - /// `error_wire(...)` response in the returned bytes and neither - /// stream is touched. - #[unsafe(no_mangle)] - pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchFullStreaming< - 'local, - >( - mut unowned_env: EnvUnowned<'local>, - _class: JClass<'local>, - header_bytes: JByteArray<'local>, - input_stream: JObject<'local>, - output_stream: JObject<'local>, - ) -> jbyteArray { - unowned_env - .with_env(|env| -> jni::errors::Result> { - let Ok(header_input) = env.convert_byte_array(&header_bytes) else { - let err = vespera_inprocess::error_wire( - 400, - "invalid header byte array (JNI conversion failed)", - ); - return Ok(env.byte_array_from_slice(&err)?.into()); - }; - - let input_global: Global> = env.new_global_ref(&input_stream)?; - let output_global: Global> = env.new_global_ref(&output_stream)?; - let jvm = env.get_java_vm()?; - - // Closures capture clones of the JavaVM and Globals; - // both types are Send+Sync. - let pull_jvm = jvm.clone(); - let pull_global = input_global; - let push_jvm = jvm; - let push_global = output_global; - - let header_response = - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - RUNTIME.block_on(vespera_inprocess::dispatch_bidirectional_streaming( - header_input, - // Pull request body chunks from Java InputStream. - // Runs on a tokio blocking thread (spawn_blocking - // inside dispatch_bidirectional_streaming). - move || -> Option> { - let result: jni::errors::Result>> = pull_jvm - .attach_current_thread(|env| { - env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { - let arr = env.new_byte_array(STREAMING_CHUNK_SIZE)?; - let n = env - .call_method( - &pull_global, - jni_str!("read"), - jni_sig!("([B)I"), - &[JValue::Object(arr.as_ref())], - )? - .i()?; - if env.exception_check() { - env.exception_clear(); - } - if n <= 0 { - return Ok(None); - } - let mut data = env.convert_byte_array(&arr)?; - data.truncate(usize::try_from(n).unwrap_or(0)); - Ok(Some(data)) - }) - }); - result.ok().flatten() - }, - // Push response body chunks to Java OutputStream. - // Runs on the tokio worker driving the dispatch. - |chunk: &[u8]| { - let _ = push_jvm.attach_current_thread( - |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { - env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { - let arr = env.byte_array_from_slice(chunk)?; - let arr_obj: JObject = arr.into(); - env.call_method( - &push_global, - jni_str!("write"), - jni_sig!("([B)V"), - &[JValue::Object(&arr_obj)], - )?; - if env.exception_check() { - env.exception_clear(); - } - Ok(()) - }) - }, - ); - }, - )) - })) - .unwrap_or_else(|_| vespera_inprocess::error_wire(500, "panic in Rust engine")); - - Ok(env.byte_array_from_slice(&header_response)?.into()) - }) - .resolve::() - .into_raw() - } - - /// `com.devfive.vespera.bridge.VesperaBridge.dispatchStreamingWithHeader(byte[], Consumer, OutputStream) -> void` - /// - /// Same as [`Java_...dispatchStreaming`] but emits the wire-format - /// response header via `headerConsumer.accept(byte[])` **before** - /// the first body byte reaches `outputStream`. This lets - /// Spring-style `HttpServletResponse` controllers commit status - /// and headers while the response is still uncommitted. - /// - /// `headerConsumer` is invoked exactly once on every code path - /// (success or error); the bytes are a normal wire-format header - /// (length-prefixed JSON). On error `outputStream` is not - /// touched. - #[unsafe(no_mangle)] - pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchStreamingWithHeader< - 'local, - >( - mut unowned_env: EnvUnowned<'local>, - _class: JClass<'local>, - request_bytes: JByteArray<'local>, - header_consumer: JObject<'local>, - output_stream: JObject<'local>, - ) { - let _ = unowned_env.with_env(|env| -> jni::errors::Result<()> { - let Ok(input) = env.convert_byte_array(&request_bytes) else { - let err = vespera_inprocess::error_wire( - 400, - "invalid input byte array (JNI conversion failed)", - ); - let _ = call_header_consumer(env, &env.new_global_ref(&header_consumer)?, &err); - return Ok(()); - }; - - let header_global: Global> = env.new_global_ref(&header_consumer)?; - let stream_global: Global> = env.new_global_ref(&output_stream)?; - let jvm = env.get_java_vm()?; - - // Panic safety: catch_unwind absorbs Rust panics so the - // JVM never sees an unwinding stack across the FFI - // boundary. If the panic happens AFTER the header - // callback fires (the common case — most panics are in - // axum handlers), Spring's response is already partially - // committed; we have no way to recover that. If the - // panic happens BEFORE the header callback fires (very - // rare — e.g. wire parse), the Java side will see a - // dangling controller; document that follow-up callers - // should set a timeout. - let _panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let header_for_cb = header_global; - let stream_for_cb = stream_global; - let jvm_for_cb = jvm; - RUNTIME.block_on(vespera_inprocess::dispatch_streaming_with_header_async( - input, - |header_bytes: &[u8]| { - let _ = jvm_for_cb.attach_current_thread( - |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { - call_header_consumer(env, &header_for_cb, header_bytes) - }, - ); - }, - |chunk: &[u8]| { - let _ = jvm_for_cb.attach_current_thread( - |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { - env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { - write_chunk_to_stream(env, &stream_for_cb, chunk) - }) - }, - ); - }, - )); - })); - - Ok(()) - }); - } - - /// `com.devfive.vespera.bridge.VesperaBridge.dispatchFullStreamingWithHeader(byte[], Consumer, InputStream, OutputStream) -> void` - /// - /// Bidirectional streaming with the same header-callback contract - /// as [`Java_...dispatchStreamingWithHeader`]. Request body - /// pulled from `inputStream`, response header emitted via - /// `headerConsumer.accept(byte[])` once axum produces status + - /// headers, then response body chunks streamed to `outputStream`. - #[unsafe(no_mangle)] - pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchFullStreamingWithHeader< - 'local, - >( - mut unowned_env: EnvUnowned<'local>, - _class: JClass<'local>, - header_bytes_in: JByteArray<'local>, - header_consumer: JObject<'local>, - input_stream: JObject<'local>, - output_stream: JObject<'local>, - ) { - let _ = unowned_env.with_env(|env| -> jni::errors::Result<()> { - let Ok(header_input) = env.convert_byte_array(&header_bytes_in) else { - let err = vespera_inprocess::error_wire( - 400, - "invalid header byte array (JNI conversion failed)", - ); - let _ = call_header_consumer(env, &env.new_global_ref(&header_consumer)?, &err); - return Ok(()); - }; - - let header_global: Global> = env.new_global_ref(&header_consumer)?; - let input_global: Global> = env.new_global_ref(&input_stream)?; - let output_global: Global> = env.new_global_ref(&output_stream)?; - let jvm = env.get_java_vm()?; - - let pull_jvm = jvm.clone(); - let pull_global = input_global; - let push_jvm = jvm.clone(); - let push_global = output_global; - let header_jvm = jvm; - let header_for_cb = header_global; - - // See dispatchStreamingWithHeader: panic absorbed silently, - // recovery semantics depend on which side of the header - // callback the panic landed. - let _panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - RUNTIME.block_on( - vespera_inprocess::dispatch_bidirectional_streaming_with_header( - header_input, - move || -> Option> { - let result: jni::errors::Result>> = pull_jvm - .attach_current_thread(|env| { - env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { - let arr = env.new_byte_array(STREAMING_CHUNK_SIZE)?; - let n = env - .call_method( - &pull_global, - jni_str!("read"), - jni_sig!("([B)I"), - &[JValue::Object(arr.as_ref())], - )? - .i()?; - if env.exception_check() { - env.exception_clear(); - } - if n <= 0 { - return Ok(None); - } - let mut data = env.convert_byte_array(&arr)?; - data.truncate(usize::try_from(n).unwrap_or(0)); - Ok(Some(data)) - }) - }); - result.ok().flatten() - }, - |chunk: &[u8]| { - let _ = push_jvm.attach_current_thread( - |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { - env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { - write_chunk_to_stream(env, &push_global, chunk) - }) - }, - ); - }, - |header_bytes: &[u8]| { - let _ = header_jvm.attach_current_thread( - |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { - call_header_consumer(env, &header_for_cb, header_bytes) - }, - ); - }, - ), - ); - })); - - Ok(()) - }); - } - - fn call_header_consumer( - env: &mut jni::Env<'_>, - consumer: &Global>, - header_bytes: &[u8], - ) -> jni::errors::Result<()> { - env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { - let arr = env.byte_array_from_slice(header_bytes)?; - let arr_obj: JObject = arr.into(); - env.call_method( - consumer, - jni_str!("accept"), - jni_sig!("(Ljava/lang/Object;)V"), - &[JValue::Object(&arr_obj)], - )?; - if env.exception_check() { - env.exception_clear(); - } - Ok(()) - }) - } - - fn write_chunk_to_stream( - env: &mut jni::Env<'_>, - stream: &Global>, - chunk: &[u8], - ) -> jni::errors::Result<()> { - let arr = env.byte_array_from_slice(chunk)?; - let arr_obj: JObject = arr.into(); - env.call_method( - stream, - jni_str!("write"), - jni_sig!("([B)V"), - &[JValue::Object(&arr_obj)], - )?; - if env.exception_check() { - env.exception_clear(); - } - Ok(()) - } - - /// Call `CompletableFuture.complete(byte[])` and clear any pending - /// JNI exception so the worker thread is left clean for subsequent - /// dispatches. - fn complete_future( - env: &mut jni::Env<'_>, - future: &Global>, - bytes: &[u8], - ) -> jni::errors::Result<()> { - let arr = env.byte_array_from_slice(bytes)?; - let arr_obj: JObject = arr.into(); - env.call_method( - future, - jni_str!("complete"), - jni_sig!("(Ljava/lang/Object;)Z"), - &[JValue::Object(&arr_obj)], - )?; - // Always clear any leftover exception (e.g. if Java's - // complete() threw via a buggy whenComplete handler): we MUST - // NOT leave the attached thread in a faulted state because - // subsequent JNI calls will misbehave silently. - if env.exception_check() { - env.exception_clear(); - } - Ok(()) - } -} +mod daemon_env; +#[cfg(not(tarpaulin_include))] +mod jni_impl; +#[cfg(not(tarpaulin_include))] +mod streaming_closures; diff --git a/crates/vespera_jni/src/streaming_closures.rs b/crates/vespera_jni/src/streaming_closures.rs new file mode 100644 index 00000000..02ff2c52 --- /dev/null +++ b/crates/vespera_jni/src/streaming_closures.rs @@ -0,0 +1,422 @@ +//! Streaming closure factories and Java-side callback helpers. +//! +//! These helpers are shared by every `dispatch*Streaming*` JNI +//! entry symbol in [`crate::jni_impl`]. They are split out into +//! a sibling module so: +//! +//! * `jni_impl.rs` stays inside the repo's 1000-line file cap +//! while keeping every `Java_..._dispatch*` symbol together. +//! * The `JMethodID` cache for the per-chunk `InputStream.read` / +//! `OutputStream.write` calls and the repeated callback helpers +//! (`Consumer.accept` / `CompletableFuture.complete`) stays beside +//! the only call sites that rely on it. +//! +//! All items are `pub(crate)` — never re-exported from the crate +//! root — so the JNI ABI surface (the `Java_...` symbols) lives +//! exclusively in [`crate::jni_impl`]. + +use std::sync::OnceLock; + +use jni::ids::JMethodID; +use jni::objects::{JClass, JObject}; +use jni::refs::Global; +use jni::signature::{MethodSignature, Primitive, ReturnType}; +use jni::strings::JNIStr; +use jni::sys::{jint, jvalue}; +use jni::{JValue, JValueOwned, jni_sig, jni_str}; + +use crate::daemon_env::with_cached_daemon_env; +use crate::jni_impl::streaming_chunk_size; + +struct CachedMethod { + _class: Global>, + method_id: JMethodID, +} + +impl CachedMethod { + fn resolve<'sig, 'sig_args, C, N, S>( + env: &mut jni::Env<'_>, + class_name: C, + method_name: N, + method_sig: S, + ) -> jni::errors::Result + where + C: AsRef, + N: AsRef, + S: AsRef>, + { + let class = env.find_class(class_name)?; + let method_id = env.get_method_id(&class, method_name, method_sig)?; + let class = env.new_global_ref(&class)?; + Ok(Self { + _class: class, + method_id, + }) + } + + fn method_id(&self) -> JMethodID { + // `_class` pins the Java class for as long as this method ID is cached: + // JNI method IDs can be invalidated if their class unloads. + self.method_id + } +} + +struct MethodCache { + input_stream_read: CachedMethod, + output_stream_write: CachedMethod, + consumer_accept: CachedMethod, + future_complete: CachedMethod, +} + +impl MethodCache { + fn resolve(env: &mut jni::Env<'_>) -> jni::errors::Result { + env.with_local_frame::<_, _, jni::errors::Error>(16, |env| { + Ok(Self { + input_stream_read: CachedMethod::resolve( + env, + jni_str!("java/io/InputStream"), + jni_str!("read"), + jni_sig!("([B)I"), + )?, + output_stream_write: CachedMethod::resolve( + env, + jni_str!("java/io/OutputStream"), + jni_str!("write"), + jni_sig!("([BII)V"), + )?, + consumer_accept: CachedMethod::resolve( + env, + jni_str!("java/util/function/Consumer"), + jni_str!("accept"), + jni_sig!("(Ljava/lang/Object;)V"), + )?, + future_complete: CachedMethod::resolve( + env, + jni_str!("java/util/concurrent/CompletableFuture"), + jni_str!("complete"), + jni_sig!("(Ljava/lang/Object;)Z"), + )?, + }) + }) + } +} + +static METHOD_CACHE: OnceLock = OnceLock::new(); + +fn method_cache(env: &mut jni::Env<'_>) -> Option<&'static MethodCache> { + if let Some(cache) = METHOD_CACHE.get() { + return Some(cache); + } + + let Ok(cache) = MethodCache::resolve(env) else { + // Cache init is best-effort. If class lookup, method lookup, + // or global-ref promotion fails, clear only that init-time + // exception and run the exact old string-based call path below. + if env.exception_check() { + env.exception_clear(); + } + return None; + }; + + let _ = METHOD_CACHE.set(cache); + METHOD_CACHE.get() +} + +fn can_call_unchecked(obj: &Global>) -> bool { + !obj.as_ref().as_raw().is_null() +} + +fn call_cached_method<'local>( + env: &mut jni::Env<'local>, + obj: &Global>, + method: &CachedMethod, + ret_ty: ReturnType, + args: &[jvalue], +) -> jni::errors::Result> { + // SAFETY: every `CachedMethod` is resolved by the JVM from a + // bootstrap `java.*` class using the exact name/signature strings + // previously passed to `Env::call_method`, and its `Global` + // pins that class for the process lifetime. Each caller builds raw + // `jvalue` arguments from the same `JValue` list as the former + // checked call and passes the matching `ReturnType`; null receivers + // are routed to the checked fallback before reaching this helper. + unsafe { env.call_method_unchecked(obj, method.method_id(), ret_ty, args) } +} + +fn call_input_stream_read( + env: &mut jni::Env<'_>, + stream: &Global>, + buf: &Global>, +) -> jni::errors::Result { + if can_call_unchecked(stream) + && let Some(cache) = method_cache(env) + { + let args: [jvalue; 1] = [JValue::Object(buf.as_ref()).as_jni()]; + return call_cached_method( + env, + stream, + &cache.input_stream_read, + ReturnType::Primitive(Primitive::Int), + &args, + )? + .i(); + } + + env.call_method( + stream, + jni_str!("read"), + jni_sig!("([B)I"), + &[JValue::Object(buf.as_ref())], + )? + .i() +} + +fn call_output_stream_write( + env: &mut jni::Env<'_>, + stream: &Global>, + buf: &Global>, + len: jint, +) -> jni::errors::Result<()> { + if can_call_unchecked(stream) + && let Some(cache) = method_cache(env) + { + let args: [jvalue; 3] = [ + JValue::Object(buf.as_ref()).as_jni(), + JValue::Int(0).as_jni(), + JValue::Int(len).as_jni(), + ]; + call_cached_method( + env, + stream, + &cache.output_stream_write, + ReturnType::Primitive(Primitive::Void), + &args, + )?; + return Ok(()); + } + + env.call_method( + stream, + jni_str!("write"), + jni_sig!("([BII)V"), + &[ + JValue::Object(buf.as_ref()), + JValue::Int(0), + JValue::Int(len), + ], + )?; + Ok(()) +} + +fn call_consumer_accept( + env: &mut jni::Env<'_>, + consumer: &Global>, + arg: &JObject<'_>, +) -> jni::errors::Result<()> { + if can_call_unchecked(consumer) + && let Some(cache) = method_cache(env) + { + let args: [jvalue; 1] = [JValue::Object(arg).as_jni()]; + call_cached_method( + env, + consumer, + &cache.consumer_accept, + ReturnType::Primitive(Primitive::Void), + &args, + )?; + return Ok(()); + } + + env.call_method( + consumer, + jni_str!("accept"), + jni_sig!("(Ljava/lang/Object;)V"), + &[JValue::Object(arg)], + )?; + Ok(()) +} + +fn call_future_complete( + env: &mut jni::Env<'_>, + future: &Global>, + arg: &JObject<'_>, +) -> jni::errors::Result<()> { + if can_call_unchecked(future) + && let Some(cache) = method_cache(env) + { + let args: [jvalue; 1] = [JValue::Object(arg).as_jni()]; + call_cached_method( + env, + future, + &cache.future_complete, + ReturnType::Primitive(Primitive::Boolean), + &args, + )?; + return Ok(()); + } + + env.call_method( + future, + jni_str!("complete"), + jni_sig!("(Ljava/lang/Object;)Z"), + &[JValue::Object(arg)], + )?; + Ok(()) +} + +/// Build the request-body pull closure shared by the two +/// full-streaming JNI entry points. +/// +/// The Java-side chunk buffer (`buf`) is allocated **once** by the +/// caller and promoted to a global ref — reused across every +/// chunk instead of `new_byte_array` per chunk. Bytes are copied +/// out via `get_byte_array_region`, which copies **only the `n` +/// bytes actually read** (the previous `convert_byte_array` +/// approach copied the full 16 KiB buffer regardless and then +/// truncated). +pub fn make_pull_closure( + jvm: jni::JavaVM, + stream: Global>, + buf: Global>, +) -> impl FnMut() -> vespera_inprocess::RequestChunk + Send + 'static { + use vespera_inprocess::RequestChunk; + let chunk_size = streaming_chunk_size(); + move || -> RequestChunk { + // Daemon-attach this (Tokio `spawn_blocking`) thread once, + // cached in TLS, instead of attach+detach per chunk; the helper + // also wraps the body in a fresh local-reference frame. + let result: jni::errors::Result = with_cached_daemon_env(&jvm, |env| { + let n = call_input_stream_read(env, &stream, &buf)?; + if env.exception_check() { + env.exception_clear(); + } + // InputStream.read(byte[]) contract (mirrored in the + // VesperaBridge javadoc): -1 = EOF, 0 = empty read that + // MUST be retried. The inprocess producer skips empty + // chunks and keeps pulling, so report `0` as an empty + // chunk rather than end-of-stream. + if n < 0 { + return Ok(RequestChunk::End); + } + if n == 0 { + return Ok(RequestChunk::Data(Vec::new())); + } + let n = usize::try_from(n).expect("positive read length fits usize"); + let n = n.min(chunk_size); + let mut data = vec![0u8; n]; + // SAFETY: `u8` and `i8` (JNI's `jbyte`) have + // identical size/alignment; this views the + // freshly allocated buffer as the signed slice + // `get_byte_array_region` expects. + let data_i8 = + unsafe { std::slice::from_raw_parts_mut(data.as_mut_ptr().cast::(), n) }; + let arr: &jni::objects::JByteArray<'_> = buf.as_ref(); + arr.get_region(env, 0, data_i8)?; + Ok(RequestChunk::Data(data)) + }); + // A JNI failure here — most importantly a `InputStream.read` + // that threw (jni-rs surfaces a pending Java exception as + // `Err`) — aborts the request body via `RequestChunk::Error` + // instead of being silently mistaken for a clean EOF, so a + // truncated upload is rejected rather than accepted as complete. + result.unwrap_or(RequestChunk::Error) + } +} +/// Build the response-body push closure shared by all four +/// streaming JNI entry points. +/// +/// The Java-side buffer (`buf`, [`streaming_chunk_size`] bytes) is +/// allocated **once** by the caller and reused for every chunk via +/// `JByteArray::set_region` + `OutputStream.write(byte[], int, int)` +/// — the previous implementation allocated a fresh exact-size Java +/// array per chunk (`byte_array_from_slice`). Axum body frames are +/// unbounded in size, so frames larger than the buffer are written +/// in buffer-sized segments. +/// +/// NOTE: when request pull and response push run concurrently +/// (bidirectional streaming), each side MUST own a **separate** +/// buffer — they execute on different threads. +pub fn make_push_closure( + jvm: jni::JavaVM, + stream: Global>, + buf: Global>, +) -> impl FnMut(&[u8]) + Send + 'static { + let chunk_size = streaming_chunk_size(); + // Latches once the Java OutputStream errors (e.g. the client + // disconnected mid-download): subsequent frames become a cheap + // no-op instead of repeatedly crossing JNI to write into a broken + // sink and clearing the resulting exception every time. + let mut failed = false; + move |chunk: &[u8]| { + if failed { + return; + } + // Daemon-attach this thread once, cached in TLS, instead of + // attach+detach per frame; the helper wraps the body in a fresh + // local-reference frame. + let outcome = with_cached_daemon_env(&jvm, |env| -> jni::errors::Result<()> { + let arr: &jni::objects::JByteArray<'_> = buf.as_ref(); + for seg in chunk.chunks(chunk_size) { + // SAFETY: `u8` and `i8` (JNI's `jbyte`) have + // identical size/alignment; this views the + // segment as the signed slice `set_region` + // expects. `seg.len() <= chunk_size` (max + // 8 MiB) so it always fits both the buffer + // and `i32`. + let seg_i8 = + unsafe { std::slice::from_raw_parts(seg.as_ptr().cast::(), seg.len()) }; + arr.set_region(env, 0, seg_i8)?; + let len = i32::try_from(seg.len()) + .expect("segment length bounded by streaming_chunk_size"); + call_output_stream_write(env, &stream, &buf, len)?; + // Any IOException thrown by write() is left + // pending on the env; clear it so subsequent + // chunks on the same thread aren't poisoned. + if env.exception_check() { + env.exception_clear(); + } + } + Ok(()) + }); + if outcome.is_err() { + failed = true; + } + } +} + +pub fn call_header_consumer( + env: &mut jni::Env<'_>, + consumer: &Global>, + header_bytes: &[u8], +) -> jni::errors::Result<()> { + env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { + let arr = env.byte_array_from_slice(header_bytes)?; + let arr_obj: JObject = arr.into(); + call_consumer_accept(env, consumer, &arr_obj)?; + if env.exception_check() { + env.exception_clear(); + } + Ok(()) + }) +} + +/// Call `CompletableFuture.complete(byte[])` and clear any pending +/// JNI exception so the worker thread is left clean for subsequent +/// dispatches. +pub fn complete_future( + env: &mut jni::Env<'_>, + future: &Global>, + bytes: &[u8], +) -> jni::errors::Result<()> { + let arr = env.byte_array_from_slice(bytes)?; + let arr_obj: JObject = arr.into(); + call_future_complete(env, future, &arr_obj)?; + // Always clear any leftover exception (e.g. if Java's + // complete() threw via a buggy whenComplete handler): we MUST + // NOT leave the attached thread in a faulted state because + // subsequent JNI calls will misbehave silently. + if env.exception_check() { + env.exception_clear(); + } + Ok(()) +} diff --git a/crates/vespera_macro/Cargo.toml b/crates/vespera_macro/Cargo.toml index a0534ac2..6d854b66 100644 --- a/crates/vespera_macro/Cargo.toml +++ b/crates/vespera_macro/Cargo.toml @@ -29,7 +29,8 @@ serde_json = "1.0" [dev-dependencies] rstest = "0.26" -insta = "1.47" +insta = "1.48" +prettyplease = "0.2" tempfile = "3" serial_test = "3" diff --git a/crates/vespera_macro/src/collector.rs b/crates/vespera_macro/src/collector.rs index 28e5534a..90b507b0 100644 --- a/crates/vespera_macro/src/collector.rs +++ b/crates/vespera_macro/src/collector.rs @@ -5,6 +5,11 @@ use std::path::Path; use syn::Item; +mod path_scan; + +use path_scan::normalize_path_key; +pub use path_scan::{fingerprints_from_scan, scan_route_folder}; + use crate::{ error::{MacroResult, err_call_site}, file_utils::{collect_files, file_to_segments}, @@ -28,18 +33,41 @@ pub fn collect_metadata( folder_name: &str, route_storage: &[StoredRouteInfo], ) -> MacroResult<(CollectedMetadata, HashMap)> { - let mut metadata = CollectedMetadata::new(); - let files = collect_files(folder_path).map_err(|e| err_call_site(format!("vespera! macro: failed to scan route folder '{}': {}. Verify the folder exists and is readable.", folder_path.display(), e)))?; + collect_metadata_from_files(&files, folder_path, folder_name, route_storage) +} + +/// [`collect_metadata`] over a **pre-scanned** file list — lets +/// `vespera!` reuse the single directory walk it already performed +/// for cache fingerprinting instead of walking the folder twice. +#[allow(clippy::option_if_let_else, clippy::too_many_lines)] +pub fn collect_metadata_from_files( + files: &[std::path::PathBuf], + folder_path: &Path, + folder_name: &str, + route_storage: &[StoredRouteInfo], +) -> MacroResult<(CollectedMetadata, HashMap)> { + let mut metadata = CollectedMetadata::new(); let mut file_asts = HashMap::with_capacity(files.len()); - // Index ROUTE_STORAGE entries by file path for O(1) lookup - let storage_by_file: HashMap<&str, Vec<&StoredRouteInfo>> = { - let mut map: HashMap<&str, Vec<&StoredRouteInfo>> = HashMap::new(); + // Index ROUTE_STORAGE entries by **canonicalized** file path for O(1) + // lookup. `#[route]` records `Span::local_file()`, which rustc + // reports relative to its invocation directory (e.g. + // `src\routes\users.rs`), while the collector walks + // `{CARGO_MANIFEST_DIR}/src/{folder}` producing absolute paths with + // platform separators. Comparing the raw strings never matches — + // silently disabling the fast path and re-parsing every route file + // on each cache miss. Canonicalizing both sides makes the keys + // comparable regardless of cwd-relativity or separator style. + let cwd = std::env::current_dir().unwrap_or_default(); + let storage_by_file: HashMap> = { + let mut map: HashMap> = HashMap::new(); for stored in route_storage { if let Some(ref fp) = stored.file_path { - map.entry(fp.as_str()).or_default().push(stored); + map.entry(normalize_path_key(fp, &cwd)) + .or_default() + .push(stored); } } map @@ -50,9 +78,8 @@ pub fn collect_metadata( continue; } - let file_path = file.display().to_string(); + let mut file_path = file.display().to_string(); - // Get module path (cheap — no parsing needed) let segments = file .strip_prefix(folder_path) .map(|file_stem| file_to_segments(file_stem, folder_path)) @@ -65,18 +92,22 @@ pub fn collect_metadata( )) })?; - let module_path = if folder_name.is_empty() { + let mut module_path = if folder_name.is_empty() { segments.join("::") } else { format!("{}::{}", folder_name, segments.join("::")) }; - // Pre-compute base path once per file (avoids repeated segments.join per route) let base_path = format!("/{}", segments.join("/")); // Fast path: ROUTE_STORAGE has entries for this file — skip syn::parse_file() - if let Some(stored_routes) = storage_by_file.get(file_path.as_str()) { - for stored in stored_routes { + // + // Per-file invariants (`module_path`, `file_path`) are CLONED for + // every non-last route but MOVED into the last route's push — + // refcount-free amortization of two String allocations per file. + if let Some(stored_routes) = storage_by_file.get(&normalize_path_key(&file_path, &cwd)) { + let n = stored_routes.len(); + for (i, stored) in stored_routes.iter().enumerate() { let route_path = if let Some(ref custom_path) = stored.custom_path { let trimmed_base = base_path.trim_end_matches('/'); format!("{trimmed_base}/{}", custom_path.trim_start_matches('/')) @@ -85,20 +116,33 @@ pub fn collect_metadata( }; let route_path = route_path.replace('_', "-"); - // Extract doc comment from fn_item_str if no explicit description - let description = stored.description.clone().or_else(|| { - syn::parse_str::(&stored.fn_item_str) - .ok() - .and_then(|fn_item| extract_doc_comment(&fn_item.attrs)) - }); + // `#[route]` already resolved the description at expansion + // time (explicit attribute OR doc comment — see + // `process_route_attribute`), so `stored.description` is + // authoritative. Re-parsing `fn_item_str` here could never + // find a doc comment the attribute macro didn't. + let description = stored.description.clone(); + + let (mp, fp) = if i + 1 == n { + ( + std::mem::take(&mut module_path), + std::mem::take(&mut file_path), + ) + } else { + (module_path.clone(), file_path.clone()) + }; metadata.routes.push(RouteMetadata { - method: stored.method.clone().unwrap_or_default(), + // `#[route]` bare form defaults to GET — mirror the + // slow path (`route::utils`), which resolves a + // missing method to "get". `unwrap_or_default()` + // produced "" here, silently dropping such routes + // from the OpenAPI doc when the fast path is active. + method: stored.method.clone().unwrap_or_else(|| "get".to_string()), path: route_path, function_name: stored.fn_name.clone(), - module_path: module_path.clone(), - file_path: file_path.clone(), - signature: stored.fn_item_str.clone(), + module_path: mp, + file_path: fp, error_status: stored.error_status.clone(), tags: stored.tags.clone(), description, @@ -109,78 +153,64 @@ pub fn collect_metadata( // #[derive(Schema)] already extracts serde(default = "fn") values // into SCHEMA_STORAGE.field_defaults (Priority 0 in process_default_functions) } else { - // Slow path: full parsing (fallback for files not in ROUTE_STORAGE) - // Uses get_parsed_file: single syn::parse_file entry point + content cache - let file_ast = crate::schema_macro::file_cache::get_parsed_file(&file).ok_or_else(|| err_call_site(format!("vespera! macro: cannot read or parse '{}'. Fix the Rust syntax errors in this file.", file.display())))?; + let file_ast = crate::schema_macro::file_cache::get_parsed_file(file).ok_or_else(|| err_call_site(format!("vespera! macro: cannot read or parse '{}'. Fix the Rust syntax errors in this file.", file.display())))?; - // Store file AST for downstream reuse file_asts.insert(file_path.clone(), file_ast); let file_ast = &file_asts[&file_path]; - // Collect routes from AST + // Pre-collect (fn_item, owned RouteInfo) pairs so we can + // 1. detect the last route up-front (symmetric with fast path), + // 2. MOVE owned RouteInfo fields (method / error_status / tags / + // description) into RouteMetadata instead of re-cloning them. + let mut route_entries: Vec<(&syn::ItemFn, crate::route::RouteInfo)> = Vec::new(); for item in &file_ast.items { if let Item::Fn(fn_item) = item && let Some(route_info) = extract_route_info(&fn_item.attrs) { - let route_path = if let Some(custom_path) = &route_info.path { - let trimmed_base = base_path.trim_end_matches('/'); - format!("{trimmed_base}/{}", custom_path.trim_start_matches('/')) - } else { - base_path.clone() - }; - let route_path = route_path.replace('_', "-"); - - // Description priority: route attribute > doc comment - let description = route_info - .description - .clone() - .or_else(|| extract_doc_comment(&fn_item.attrs)); - - metadata.routes.push(RouteMetadata { - method: route_info.method, - path: route_path, - function_name: fn_item.sig.ident.to_string(), - module_path: module_path.clone(), - file_path: file_path.clone(), - signature: quote::quote!(#fn_item).to_string(), - error_status: route_info.error_status.clone(), - tags: route_info.tags.clone(), - description, - }); + route_entries.push((fn_item, route_info)); } } - } - } - Ok((metadata, file_asts)) -} + let n = route_entries.len(); + for (i, (fn_item, route_info)) in route_entries.into_iter().enumerate() { + let route_path = if let Some(custom_path) = &route_info.path { + let trimmed_base = base_path.trim_end_matches('/'); + format!("{trimmed_base}/{}", custom_path.trim_start_matches('/')) + } else { + base_path.clone() + }; + let route_path = route_path.replace('_', "-"); -/// Collect file modification times without reading content. -/// Used for cache invalidation — much cheaper than full `collect_metadata()`. -pub fn collect_file_fingerprints(folder_path: &Path) -> MacroResult> { - let files = collect_files(folder_path).map_err(|e| { - err_call_site(format!( - "vespera! macro: failed to scan route folder '{}': {}", - folder_path.display(), - e - )) - })?; - - let mut fingerprints = HashMap::with_capacity(files.len()); - for file in files { - if file.extension().is_none_or(|e| e != "rs") { - continue; + // Description priority: route attribute > doc comment + // (move the owned Option instead of cloning + dropping it) + let description = route_info + .description + .or_else(|| extract_doc_comment(&fn_item.attrs)); + + let (mp, fp) = if i + 1 == n { + ( + std::mem::take(&mut module_path), + std::mem::take(&mut file_path), + ) + } else { + (module_path.clone(), file_path.clone()) + }; + + metadata.routes.push(RouteMetadata { + method: route_info.method, + path: route_path, + function_name: fn_item.sig.ident.to_string(), + module_path: mp, + file_path: fp, + error_status: route_info.error_status, + tags: route_info.tags, + description, + }); + } } - let mtime = std::fs::metadata(&file) - .and_then(|m| m.modified()) - .map_or(0, |t| { - t.duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - }); - fingerprints.insert(file.display().to_string(), mtime); } - Ok(fingerprints) + + Ok((metadata, file_asts)) } #[cfg(test)] @@ -210,8 +240,6 @@ mod tests { assert!(metadata.routes.is_empty()); assert!(metadata.structs.is_empty()); - - drop(temp_dir); } #[rstest] @@ -220,11 +248,11 @@ mod tests { vec![( "users.rs", r#" -#[route(get)] -pub fn get_users() -> String { + #[route(get)] + pub fn get_users() -> String { "users".to_string() -} -"#, + } + "#, )], "get", "/users", @@ -236,11 +264,11 @@ pub fn get_users() -> String { vec![( "create_user.rs", r#" -#[route(post)] -pub fn create_user() -> String { + #[route(post)] + pub fn create_user() -> String { "created".to_string() -} -"#, + } + "#, )], "post", "/create-user", @@ -252,11 +280,11 @@ pub fn create_user() -> String { vec![( "users.rs", r#" -#[route(get, path = "/api/users")] -pub fn get_users() -> String { + #[route(get, path = "/api/users")] + pub fn get_users() -> String { "users".to_string() -} -"#, + } + "#, )], "get", "/users/api/users", @@ -268,11 +296,11 @@ pub fn get_users() -> String { vec![( "users.rs", r#" -#[route(get, error_status = [400, 404])] -pub fn get_users() -> String { + #[route(get, error_status = [400, 404])] + pub fn get_users() -> String { "users".to_string() -} -"#, + } + "#, )], "get", "/users", @@ -284,11 +312,11 @@ pub fn get_users() -> String { vec![( "api/users.rs", r#" -#[route(get)] -pub fn get_users() -> String { + #[route(get)] + pub fn get_users() -> String { "users".to_string() -} -"#, + } + "#, )], "get", "/api/users", @@ -300,11 +328,11 @@ pub fn get_users() -> String { vec![( "api/v1/users.rs", r#" -#[route(get)] -pub fn get_users() -> String { + #[route(get)] + pub fn get_users() -> String { "users".to_string() -} -"#, + } + "#, )], "get", "/api/v1/users", @@ -339,8 +367,6 @@ pub fn get_users() -> String { .contains(first_filename.split('/').next().unwrap()) ); } - - drop(temp_dir); } #[test] @@ -351,8 +377,6 @@ pub fn get_users() -> String { let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); assert_eq!(metadata.routes.len(), 0); - - drop(temp_dir); } #[test] @@ -364,19 +388,17 @@ pub fn get_users() -> String { &temp_dir, "user.rs", r" -pub struct User { + pub struct User { pub id: i32, pub name: String, -} -", + } + ", ); let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); assert_eq!(metadata.routes.len(), 0); assert_eq!(metadata.structs.len(), 0); - - drop(temp_dir); } #[test] @@ -388,19 +410,19 @@ pub struct User { &temp_dir, "user.rs", r#" -use vespera::Schema; + use vespera::Schema; -#[derive(Schema)] -pub struct User { + #[derive(Schema)] + pub struct User { pub id: i32, pub name: String, -} + } -#[route(get)] -pub fn get_user() -> User { + #[route(get)] + pub fn get_user() -> User { User { id: 1, name: "Alice".to_string() } -} -"#, + } + "#, ); let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); @@ -409,8 +431,6 @@ pub fn get_user() -> User { let route = &metadata.routes[0]; assert_eq!(route.function_name, "get_user"); - - drop(temp_dir); } #[test] @@ -422,27 +442,27 @@ pub fn get_user() -> User { &temp_dir, "users.rs", r#" -#[route(get)] -pub fn get_users() -> String { + #[route(get)] + pub fn get_users() -> String { "users".to_string() -} + } -#[route(post)] -pub fn create_users() -> String { + #[route(post)] + pub fn create_users() -> String { "created".to_string() -} -"#, + } + "#, ); create_temp_file( &temp_dir, "posts.rs", r#" -#[route(get)] -pub fn get_posts() -> String { + #[route(get)] + pub fn get_posts() -> String { "posts".to_string() -} -"#, + } + "#, ); let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); @@ -450,7 +470,6 @@ pub fn get_posts() -> String { assert_eq!(metadata.routes.len(), 3); assert_eq!(metadata.structs.len(), 0); - // Check all routes are present let function_names: Vec<&str> = metadata .routes .iter() @@ -459,8 +478,6 @@ pub fn get_posts() -> String { assert!(function_names.contains(&"get_users")); assert!(function_names.contains(&"create_users")); assert!(function_names.contains(&"get_posts")); - - drop(temp_dir); } #[test] @@ -472,35 +489,33 @@ pub fn get_posts() -> String { &temp_dir, "user.rs", r" -use vespera::Schema; + use vespera::Schema; -#[derive(Schema)] -pub struct User { + #[derive(Schema)] + pub struct User { pub id: i32, pub name: String, -} -", + } + ", ); create_temp_file( &temp_dir, "post.rs", r" -use vespera::Schema; + use vespera::Schema; -#[derive(Schema)] -pub struct Post { + #[derive(Schema)] + pub struct Post { pub id: i32, pub title: String, -} -", + } + ", ); let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); assert_eq!(metadata.routes.len(), 0); - - drop(temp_dir); } #[test] @@ -512,11 +527,11 @@ pub struct Post { &temp_dir, "mod.rs", r#" -#[route(get)] -pub fn index() -> String { + #[route(get)] + pub fn index() -> String { "index".to_string() -} -"#, + } + "#, ); let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); @@ -526,8 +541,6 @@ pub fn index() -> String { assert_eq!(route.function_name, "index"); assert_eq!(route.path, "/"); assert_eq!(route.module_path, "routes::"); - - drop(temp_dir); } #[test] @@ -539,11 +552,11 @@ pub fn index() -> String { &temp_dir, "users.rs", r#" -#[route(get)] -pub fn get_users() -> String { + #[route(get)] + pub fn get_users() -> String { "users".to_string() -} -"#, + } + "#, ); let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); @@ -551,8 +564,6 @@ pub fn get_users() -> String { assert_eq!(metadata.routes.len(), 1); let route = &metadata.routes[0]; assert_eq!(route.module_path, "users"); - - drop(temp_dir); } #[test] @@ -564,11 +575,11 @@ pub fn get_users() -> String { &temp_dir, "users.rs", r#" -#[route(get)] -pub fn get_users() -> String { + #[route(get)] + pub fn get_users() -> String { "users".to_string() -} -"#, + } + "#, ); create_temp_file(&temp_dir, "config.txt", "some config content"); @@ -577,11 +588,8 @@ pub fn get_users() -> String { let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); - // Only .rs file should be processed assert_eq!(metadata.routes.len(), 1); assert_eq!(metadata.structs.len(), 0); - - drop(temp_dir); } #[test] @@ -593,21 +601,18 @@ pub fn get_users() -> String { &temp_dir, "valid.rs", r#" -#[route(get)] -pub fn get_users() -> String { + #[route(get)] + pub fn get_users() -> String { "users".to_string() -} -"#, + } + "#, ); create_temp_file(&temp_dir, "invalid.rs", "invalid rust syntax {"); let metadata = collect_metadata(temp_dir.path(), folder_name, &[]).map(|(m, _)| m); - // Only valid file should be processed assert!(metadata.is_err()); - - drop(temp_dir); } #[test] @@ -619,11 +624,11 @@ pub fn get_users() -> String { &temp_dir, "users.rs", r#" -#[route(get, error_status = [400, 404, 500])] -pub fn get_users() -> String { + #[route(get, error_status = [400, 404, 500])] + pub fn get_users() -> String { "users".to_string() -} -"#, + } + "#, ); let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); @@ -637,8 +642,6 @@ pub fn get_users() -> String { assert!(error_status.contains(&400)); assert!(error_status.contains(&404)); assert!(error_status.contains(&500)); - - drop(temp_dir); } #[test] @@ -650,27 +653,27 @@ pub fn get_users() -> String { &temp_dir, "routes.rs", r#" -#[route(get)] -pub fn get_handler() -> String { "get".to_string() } + #[route(get)] + pub fn get_handler() -> String { "get".to_string() } -#[route(post)] -pub fn post_handler() -> String { "post".to_string() } + #[route(post)] + pub fn post_handler() -> String { "post".to_string() } -#[route(put)] -pub fn put_handler() -> String { "put".to_string() } + #[route(put)] + pub fn put_handler() -> String { "put".to_string() } -#[route(patch)] -pub fn patch_handler() -> String { "patch".to_string() } + #[route(patch)] + pub fn patch_handler() -> String { "patch".to_string() } -#[route(delete)] -pub fn delete_handler() -> String { "delete".to_string() } + #[route(delete)] + pub fn delete_handler() -> String { "delete".to_string() } -#[route(head)] -pub fn head_handler() -> String { "head".to_string() } + #[route(head)] + pub fn head_handler() -> String { "head".to_string() } -#[route(options)] -pub fn options_handler() -> String { "options".to_string() } -"#, + #[route(options)] + pub fn options_handler() -> String { "options".to_string() } + "#, ); let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); @@ -685,19 +688,15 @@ pub fn options_handler() -> String { "options".to_string() } assert!(methods.contains(&"delete")); assert!(methods.contains(&"head")); assert!(methods.contains(&"options")); - - drop(temp_dir); } #[test] fn test_collect_metadata_collect_files_error() { - // Test: collect_files returns error (non-existent directory) let non_existent_path = std::path::Path::new("/nonexistent/path/that/does/not/exist"); let folder_name = "routes"; let result = collect_metadata(non_existent_path, folder_name, &[]); - // Should return error when collect_files fails assert!(result.is_err()); let error_msg = result.unwrap_err().to_string(); assert!(error_msg.contains("failed to scan route folder")); @@ -706,7 +705,6 @@ pub fn options_handler() -> String { "options".to_string() } #[test] #[cfg(unix)] fn test_collect_metadata_file_read_error_permissions() { - // Test line 31-37: file read error due to permission denial // On Unix, we can create a file and then remove read permissions use std::fs; use std::os::unix::fs::PermissionsExt; @@ -714,20 +712,18 @@ pub fn options_handler() -> String { "options".to_string() } let temp_dir = TempDir::new().expect("Failed to create temp dir"); let folder_name = "routes"; - // Create a file with valid Rust syntax let file_path = temp_dir.path().join("unreadable.rs"); fs::write( &file_path, r#" -#[route(get)] -pub fn get_users() -> String { + #[route(get)] + pub fn get_users() -> String { "users".to_string() -} -"#, + } + "#, ) .expect("Failed to write temp file"); - // Remove read permissions let permissions = fs::Permissions::from_mode(0o000); fs::set_permissions(&file_path, permissions).expect("Failed to set permissions"); @@ -743,19 +739,14 @@ pub fn get_users() -> String { return; } - // Attempt to collect metadata - should fail with "failed to read route file" error let result = collect_metadata(temp_dir.path(), folder_name, &[]); - // Verify error message assert!(result.is_err()); let error_msg = result.unwrap_err().to_string(); assert!(error_msg.contains("failed to read route file")); - // Restore permissions so tempdir cleanup doesn't fail let permissions = fs::Permissions::from_mode(0o644); fs::set_permissions(&file_path, permissions).ok(); - - drop(temp_dir); } #[test] @@ -779,50 +770,39 @@ pub fn get_users() -> String { // This is tested indirectly via test_collect_metadata_file_read_error_via_invalid_syntax // which verifies error propagation works correctly. - // Verify the documented behavior with a comment-only test let temp_dir = TempDir::new().expect("Failed to create temp dir"); let folder_name = "routes"; - // Successfully create a readable file to verify the happy path create_temp_file( &temp_dir, "readable.rs", r#" -#[route(get)] -pub fn get() -> String { "ok".to_string() } -"#, + #[route(get)] + pub fn get() -> String { "ok".to_string() } + "#, ); let result = collect_metadata(temp_dir.path(), folder_name, &[]); assert!(result.is_ok()); - - drop(temp_dir); } #[test] fn test_collect_metadata_file_read_error_via_invalid_syntax() { - // Test line 31-37: verify error handling by parsing invalid files // While we can't easily trigger read errors on all platforms, // we verify the code path by ensuring errors are properly propagated let temp_dir = TempDir::new().expect("Failed to create temp dir"); let folder_name = "routes"; - // Create a file that will fail to parse (syntax error) create_temp_file(&temp_dir, "invalid.rs", "{{{"); - // This should fail during syntax parsing, not file reading let result = collect_metadata(temp_dir.path(), folder_name, &[]); assert!(result.is_err()); let error_msg = result.unwrap_err().to_string(); assert!(error_msg.contains("syntax error")); - - drop(temp_dir); } #[test] fn test_collect_metadata_strip_prefix_succeeds_in_normal_case() { - // Test line 49-58: strip_prefix succeeds in the normal case - // // DEFENSIVE CODE ANALYSIS (line 49-58): // The strip_prefix error path is nearly impossible to trigger in practice because: // 1. collect_files() returns paths by walking folder_path @@ -834,41 +814,32 @@ pub fn get() -> String { "ok".to_string() } // - Or if folder_path contained symlinks with different absolute paths // - Or if the filesystem changed between collect_files and this loop // - // This test verifies the normal case works correctly. let temp_dir = TempDir::new().expect("Failed to create temp dir"); let folder_name = "routes"; - // Create a subdirectory let sub_dir = temp_dir.path().join("routes"); std::fs::create_dir_all(&sub_dir).expect("Failed to create subdirectory"); - // Create a file in the subdirectory create_temp_file( &temp_dir, "routes/valid.rs", r#" -#[route(get)] -pub fn get_users() -> String { + #[route(get)] + pub fn get_users() -> String { "users".to_string() -} -"#, + } + "#, ); - // Collect metadata from the subdirectory let (metadata, _file_asts) = collect_metadata(&sub_dir, folder_name, &[]).unwrap(); - // Should collect the route (strip_prefix succeeds in normal cases) assert_eq!(metadata.routes.len(), 1); let route = &metadata.routes[0]; assert_eq!(route.function_name, "get_users"); - - drop(temp_dir); } #[test] fn test_collect_metadata_struct_without_derive() { - // Test line 81: attr.path().is_ident("derive") returns false - // Struct with non-derive attributes should not be collected let temp_dir = TempDir::new().expect("Failed to create temp dir"); let folder_name = "routes"; @@ -876,24 +847,20 @@ pub fn get_users() -> String { &temp_dir, "user.rs", r" -pub struct User { + pub struct User { pub id: i32, pub name: String, -} -", + } + ", ); let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); - // Struct without Schema derive should not be collected assert_eq!(metadata.structs.len(), 0); - - drop(temp_dir); } #[test] fn test_collect_metadata_struct_with_other_derive() { - // Test line 81: struct with other derive attributes (not Schema) let temp_dir = TempDir::new().expect("Failed to create temp dir"); let folder_name = "routes"; @@ -901,210 +868,16 @@ pub struct User { &temp_dir, "user.rs", r" -#[derive(Debug, Clone)] -pub struct User { + #[derive(Debug, Clone)] + pub struct User { pub id: i32, pub name: String, -} -", + } + ", ); let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); - // Struct with only Debug/Clone derive (no Schema) should not be collected assert_eq!(metadata.structs.len(), 0); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_fast_path_with_route_storage() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - // Create a .rs file that the fast path will match against - let file_path = create_temp_file( - &temp_dir, - "users.rs", - r#" -pub async fn get_users() -> String { - "users".to_string() -} -"#, - ); - - let file_path_str = file_path.display().to_string(); - - // Create StoredRouteInfo entries that match this file - let route_storage = vec![StoredRouteInfo { - fn_name: "get_users".to_string(), - method: Some("get".to_string()), - custom_path: None, - error_status: None, - tags: Some(vec!["users".to_string()]), - description: Some("Get all users".to_string()), - fn_item_str: "pub async fn get_users() -> String { \"users\".to_string() }".to_string(), - file_path: Some(file_path_str.clone()), - }]; - - let (metadata, file_asts) = - collect_metadata(temp_dir.path(), folder_name, &route_storage).unwrap(); - - // Fast path should produce route metadata - assert_eq!(metadata.routes.len(), 1); - let route = &metadata.routes[0]; - assert_eq!(route.function_name, "get_users"); - assert_eq!(route.method, "get"); - assert_eq!(route.tags, Some(vec!["users".to_string()])); - assert_eq!(route.description, Some("Get all users".to_string())); - assert_eq!(route.module_path, "routes::users"); - - // Fast path should NOT insert file ASTs (no parsing needed) - assert!( - file_asts.is_empty(), - "Fast path should not populate file_asts" - ); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_fast_path_with_custom_path() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - let file_path = create_temp_file( - &temp_dir, - "users.rs", - r#" -pub async fn get_user() -> String { - "user".to_string() -} -"#, - ); - - let file_path_str = file_path.display().to_string(); - - let route_storage = vec![StoredRouteInfo { - fn_name: "get_user".to_string(), - method: Some("get".to_string()), - custom_path: Some("/{id}".to_string()), - error_status: Some(vec![404]), - tags: None, - description: None, - fn_item_str: "pub async fn get_user(id: i32) -> String { \"user\".to_string() }" - .to_string(), - file_path: Some(file_path_str.clone()), - }]; - - let (metadata, _) = collect_metadata(temp_dir.path(), folder_name, &route_storage).unwrap(); - - assert_eq!(metadata.routes.len(), 1); - let route = &metadata.routes[0]; - assert_eq!(route.path, "/users/{id}"); - assert!(route.error_status.is_some()); - assert_eq!(route.error_status.as_ref().unwrap(), &vec![404]); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_fast_path_empty_folder_name() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = ""; - - let file_path = create_temp_file( - &temp_dir, - "users.rs", - r#" -pub async fn list_users() -> String { - "list".to_string() -} -"#, - ); - - let file_path_str = file_path.display().to_string(); - - let route_storage = vec![StoredRouteInfo { - fn_name: "list_users".to_string(), - method: Some("get".to_string()), - custom_path: None, - error_status: None, - tags: None, - description: None, - fn_item_str: "pub async fn list_users() -> String { \"list\".to_string() }".to_string(), - file_path: Some(file_path_str), - }]; - - let (metadata, _) = collect_metadata(temp_dir.path(), folder_name, &route_storage).unwrap(); - - assert_eq!(metadata.routes.len(), 1); - let route = &metadata.routes[0]; - // With empty folder_name, module_path should be just segments (no prefix) - assert_eq!(route.module_path, "users"); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_fast_path_doc_comment_extraction() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - let file_path = create_temp_file(&temp_dir, "items.rs", "// placeholder\n"); - - let file_path_str = file_path.display().to_string(); - - // fn_item_str includes a doc comment, description is None - // so the fast path should extract the doc comment - let route_storage = vec![StoredRouteInfo { - fn_name: "get_items".to_string(), - method: Some("get".to_string()), - custom_path: None, - error_status: None, - tags: None, - description: None, // No explicit description -> should extract from doc comment - fn_item_str: - "/// List all items\npub async fn get_items() -> String { \"items\".to_string() }" - .to_string(), - file_path: Some(file_path_str), - }]; - - let (metadata, _) = collect_metadata(temp_dir.path(), folder_name, &route_storage).unwrap(); - - assert_eq!(metadata.routes.len(), 1); - let route = &metadata.routes[0]; - // Description should be extracted from the doc comment in fn_item_str - assert_eq!(route.description, Some("List all items".to_string())); - - drop(temp_dir); - } - - #[test] - fn test_collect_file_fingerprints_skips_non_rs_files() { - // Exercises line 121: non-.rs files should be skipped - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create both .rs and non-.rs files - create_temp_file(&temp_dir, "valid.rs", "pub fn hello() {}"); - create_temp_file(&temp_dir, "readme.txt", "This is a readme"); - create_temp_file(&temp_dir, "data.json", "{}"); - create_temp_file(&temp_dir, "script.py", "print('hello')"); - - let fingerprints = collect_file_fingerprints(temp_dir.path()).unwrap(); - - // Only .rs files should be in fingerprints - assert_eq!( - fingerprints.len(), - 1, - "Only .rs files should be fingerprinted" - ); - let keys: Vec<&String> = fingerprints.keys().collect(); - assert!( - keys[0].ends_with("valid.rs"), - "The only fingerprinted file should be valid.rs" - ); - - drop(temp_dir); } } diff --git a/crates/vespera_macro/src/collector/path_scan.rs b/crates/vespera_macro/src/collector/path_scan.rs new file mode 100644 index 00000000..c54a01e1 --- /dev/null +++ b/crates/vespera_macro/src/collector/path_scan.rs @@ -0,0 +1,440 @@ +//! Route-folder scanning and the path-normalization key that makes +//! `#[route]`'s cwd-relative span paths comparable with the +//! collector's absolute walk paths (the fast-path match). + +use std::collections::HashMap; +use std::path::Path; + +use crate::error::{MacroResult, err_call_site}; + +/// Normalize a path string into a comparison key **without touching +/// the filesystem** (an earlier `fs::canonicalize` version cost one +/// syscall per lookup — ~130ms for a 300-file project on Windows). +/// +/// `#[route]` records `Span::local_file()`, which rustc reports +/// relative to its invocation directory, while the collector walks +/// `{CARGO_MANIFEST_DIR}/src/{folder}` producing absolute paths with +/// platform separators. This key makes both comparable: +/// - relative paths are absolutized against `cwd` (the same process +/// working directory rustc resolved the span path from) +/// - `.`/`..` components are folded +/// - separators normalize to `/`, the Windows `\\?\` verbatim prefix +/// is stripped, and (Windows only) the drive letter case is folded +pub(super) fn normalize_path_key(path: &str, cwd: &Path) -> String { + use std::path::Component; + + let p = Path::new(path); + let abs = if p.is_absolute() { + p.to_path_buf() + } else { + cwd.join(p) + }; + let mut folded = std::path::PathBuf::new(); + for comp in abs.components() { + match comp { + Component::CurDir => {} + Component::ParentDir => { + folded.pop(); + } + other => folded.push(other), + } + } + let mut key = folded.display().to_string().replace('\\', "/"); + if let Some(stripped) = key.strip_prefix("//?/") { + key = stripped.to_owned(); + } + if cfg!(windows) { + key.make_ascii_lowercase(); + } + key +} + +/// Single directory walk returning `(path, mtime)` pairs — the shared +/// scan that both cache fingerprinting and route collection consume. +pub fn scan_route_folder(folder_path: &Path) -> MacroResult> { + crate::file_utils::collect_files_with_mtimes(folder_path).map_err(|e| { + err_call_site(format!( + "vespera! macro: failed to scan route folder '{}': {}. Verify the folder exists and is readable.", + folder_path.display(), + e + )) + }) +} + +/// Build the cache fingerprint map (`.rs` files only) from a scan. +pub fn fingerprints_from_scan(scanned: &[(std::path::PathBuf, u64)]) -> HashMap { + scanned + .iter() + .filter(|(file, _)| file.extension().is_some_and(|e| e == "rs")) + .map(|(file, mtime)| (file.display().to_string(), *mtime)) + .collect() +} + +#[cfg(test)] +#[cfg(test)] +mod tests { + use std::fs; + + use rstest::rstest; + use tempfile::TempDir; + + use super::*; + use crate::collector::collect_metadata; + use crate::route_impl::StoredRouteInfo; + + fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> std::path::PathBuf { + let file_path = dir.path().join(filename); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).expect("Failed to create parent directory"); + } + fs::write(&file_path, content).expect("Failed to write temp file"); + file_path + } + + // + // The fast path matches `#[route]`'s `Span::local_file()` strings + // (cwd-relative) against the collector's absolute walk paths. + // Before normalization existed the keys NEVER matched and the + // fast path was silently dead — every route file was re-parsed on + // every cache miss with zero test failures. These tests pin the + // matching semantics so a regression is loud. + + #[rstest] + // Relative path resolves against cwd → equals the absolute form. + #[case("src/routes/users.rs", "/work/src/routes/users.rs", "/work")] + // Separator style must not matter. + #[case("src\\routes\\users.rs", "/work/src/routes/users.rs", "/work")] + // `.` and `..` components fold on either side. + #[case( + "src/./routes/../routes/users.rs", + "/work/src/routes/users.rs", + "/work" + )] + #[case("src/routes/users.rs", "/work/extra/../src/routes/users.rs", "/work")] + fn normalize_path_key_matches_equivalent_paths( + #[case] stored: &str, + #[case] walked: &str, + #[case] cwd: &str, + ) { + let cwd = Path::new(cwd); + assert_eq!( + normalize_path_key(stored, cwd), + normalize_path_key(walked, cwd), + "stored={stored:?} and walked={walked:?} must produce the same key" + ); + } + + #[test] + fn normalize_path_key_distinguishes_different_files() { + let cwd = Path::new("/work"); + assert_ne!( + normalize_path_key("src/routes/users.rs", cwd), + normalize_path_key("src/routes/posts.rs", cwd), + ); + } + + #[cfg(windows)] + #[test] + fn normalize_path_key_windows_verbatim_prefix_and_case() { + let cwd = Path::new("C:\\work"); + // `fs::canonicalize` output style (\\?\ verbatim prefix) must + // match plain absolute paths, and drive/file case must fold. + assert_eq!( + normalize_path_key("\\\\?\\C:\\Work\\Src\\Users.RS", cwd), + normalize_path_key("c:/work/src/users.rs", cwd), + ); + } + + /// END-TO-END lock for the fast-path activation bug: storage + /// carries a **cwd-relative** path (exactly what + /// `Span::local_file()` yields) while the collector walks an + /// absolute folder. The route file is deliberately INVALID Rust — + /// the slow path would fail with a parse error, so a successful + /// collect proves the fast path matched without parsing. + #[test] + fn fast_path_matches_cwd_relative_storage_paths_without_parsing() { + // cargo runs tests with cwd = this crate's manifest dir, so a + // path under the workspace `target/` dir has a stable relative + // form mirroring rustc's span paths. + let unique = format!("vespera_fastpath_lock_{}", std::process::id()); + let abs_dir = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("target") + .join(&unique); + fs::create_dir_all(&abs_dir).expect("create test route dir"); + fs::write( + abs_dir.join("users.rs"), + "this is deliberately not rust {{{", + ) + .expect("write route file"); + + let relative_stored_path = format!("../../target/{unique}/users.rs"); + let route_storage = vec![StoredRouteInfo { + fn_name: "get_users".to_string(), + method: None, + custom_path: None, + error_status: None, + tags: None, + description: None, + fn_item_str: "pub async fn get_users() -> String { String::new() }".to_string(), + file_path: Some(relative_stored_path), + }]; + + let result = collect_metadata(&abs_dir, "routes", &route_storage); + fs::remove_dir_all(&abs_dir).ok(); + + let (metadata, file_asts) = result.expect( + "fast path must match the relative storage path WITHOUT parsing — \ + a parse error here means key normalization regressed and the \ + slow path ran against the invalid file", + ); + assert_eq!(metadata.routes.len(), 1, "route must come from storage"); + assert!( + file_asts.is_empty(), + "fast path must not parse any file ASTs" + ); + } + + /// Lock for the method-default bug: `#[route]` without a method + /// stores `method: None`; the fast path must resolve it to "get" + /// like the slow path does. The original `unwrap_or_default()` + /// produced "" — silently dropping such routes from the OpenAPI + /// doc AND the generated router. + #[test] + fn fast_path_defaults_missing_method_to_get() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let file_path = create_temp_file(&temp_dir, "items.rs", "// placeholder\n"); + + let route_storage = vec![StoredRouteInfo { + fn_name: "list_items".to_string(), + method: None, // bare `#[route]` / `#[route(path = ...)]` + custom_path: None, + error_status: None, + tags: None, + description: None, + fn_item_str: "pub async fn list_items() -> String { String::new() }".to_string(), + file_path: Some(file_path.display().to_string()), + }]; + + let (metadata, _) = collect_metadata(temp_dir.path(), "routes", &route_storage).unwrap(); + + assert_eq!(metadata.routes.len(), 1); + assert_eq!( + metadata.routes[0].method, "get", + "missing method must default to GET — \"\" silently drops the route" + ); + + drop(temp_dir); + } + + #[test] + fn test_collect_metadata_fast_path_with_route_storage() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + // Create a .rs file that the fast path will match against + let file_path = create_temp_file( + &temp_dir, + "users.rs", + r#" + pub async fn get_users() -> String { + "users".to_string() + } + "#, + ); + + let file_path_str = file_path.display().to_string(); + + // Create StoredRouteInfo entries that match this file + let route_storage = vec![StoredRouteInfo { + fn_name: "get_users".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: None, + tags: Some(vec!["users".to_string()]), + description: Some("Get all users".to_string()), + fn_item_str: "pub async fn get_users() -> String { \"users\".to_string() }".to_string(), + file_path: Some(file_path_str.clone()), + }]; + + let (metadata, file_asts) = + collect_metadata(temp_dir.path(), folder_name, &route_storage).unwrap(); + + // Fast path should produce route metadata + assert_eq!(metadata.routes.len(), 1); + let route = &metadata.routes[0]; + assert_eq!(route.function_name, "get_users"); + assert_eq!(route.method, "get"); + assert_eq!(route.tags, Some(vec!["users".to_string()])); + assert_eq!(route.description, Some("Get all users".to_string())); + assert_eq!(route.module_path, "routes::users"); + + // Fast path should NOT insert file ASTs (no parsing needed) + assert!( + file_asts.is_empty(), + "Fast path should not populate file_asts" + ); + + drop(temp_dir); + } + + #[test] + fn test_collect_metadata_fast_path_with_custom_path() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + let file_path = create_temp_file( + &temp_dir, + "users.rs", + r#" + pub async fn get_user() -> String { + "user".to_string() + } + "#, + ); + + let file_path_str = file_path.display().to_string(); + + let route_storage = vec![StoredRouteInfo { + fn_name: "get_user".to_string(), + method: Some("get".to_string()), + custom_path: Some("/{id}".to_string()), + error_status: Some(vec![404]), + tags: None, + description: None, + fn_item_str: "pub async fn get_user(id: i32) -> String { \"user\".to_string() }" + .to_string(), + file_path: Some(file_path_str.clone()), + }]; + + let (metadata, _) = collect_metadata(temp_dir.path(), folder_name, &route_storage).unwrap(); + + assert_eq!(metadata.routes.len(), 1); + let route = &metadata.routes[0]; + assert_eq!(route.path, "/users/{id}"); + assert!(route.error_status.is_some()); + assert_eq!(route.error_status.as_ref().unwrap(), &vec![404]); + + drop(temp_dir); + } + + #[test] + fn test_collect_metadata_fast_path_empty_folder_name() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = ""; + + let file_path = create_temp_file( + &temp_dir, + "users.rs", + r#" + pub async fn list_users() -> String { + "list".to_string() + } + "#, + ); + + let file_path_str = file_path.display().to_string(); + + let route_storage = vec![StoredRouteInfo { + fn_name: "list_users".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: None, + tags: None, + description: None, + fn_item_str: "pub async fn list_users() -> String { \"list\".to_string() }".to_string(), + file_path: Some(file_path_str), + }]; + + let (metadata, _) = collect_metadata(temp_dir.path(), folder_name, &route_storage).unwrap(); + + assert_eq!(metadata.routes.len(), 1); + let route = &metadata.routes[0]; + // With empty folder_name, module_path should be just segments (no prefix) + assert_eq!(route.module_path, "users"); + + drop(temp_dir); + } + + #[test] + fn test_collect_metadata_fast_path_uses_stored_description() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + let file_path = create_temp_file(&temp_dir, "items.rs", "// placeholder\n"); + + let file_path_str = file_path.display().to_string(); + + // `#[route]` resolves the description (explicit attribute OR doc + // comment) at expansion time — see `process_route_attribute`. + // The collector fast path must pass it through verbatim WITHOUT + // re-parsing `fn_item_str`. + let route_storage = vec![StoredRouteInfo { + fn_name: "get_items".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: None, + tags: None, + description: Some("List all items".to_string()), + fn_item_str: + "/// List all items\npub async fn get_items() -> String { \"items\".to_string() }" + .to_string(), + file_path: Some(file_path_str.clone()), + }]; + + let (metadata, _) = collect_metadata(temp_dir.path(), folder_name, &route_storage).unwrap(); + + assert_eq!(metadata.routes.len(), 1); + assert_eq!( + metadata.routes[0].description, + Some("List all items".to_string()) + ); + + // A storage entry with no description stays None — the fast path + // does NOT re-extract from fn_item_str (expansion already did). + let route_storage_none = vec![StoredRouteInfo { + fn_name: "get_items".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: None, + tags: None, + description: None, + fn_item_str: "pub async fn get_items() -> String { \"items\".to_string() }".to_string(), + file_path: Some(file_path_str), + }]; + let (metadata, _) = + collect_metadata(temp_dir.path(), folder_name, &route_storage_none).unwrap(); + assert_eq!(metadata.routes[0].description, None); + + drop(temp_dir); + } + + #[test] + fn test_collect_file_fingerprints_skips_non_rs_files() { + // Exercises line 121: non-.rs files should be skipped + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create both .rs and non-.rs files + create_temp_file(&temp_dir, "valid.rs", "pub fn hello() {}"); + create_temp_file(&temp_dir, "readme.txt", "This is a readme"); + create_temp_file(&temp_dir, "data.json", "{}"); + create_temp_file(&temp_dir, "script.py", "print('hello')"); + + let fingerprints = fingerprints_from_scan(&scan_route_folder(temp_dir.path()).unwrap()); + + // Only .rs files should be in fingerprints + assert_eq!( + fingerprints.len(), + 1, + "Only .rs files should be fingerprinted" + ); + let keys: Vec<&String> = fingerprints.keys().collect(); + assert!( + keys[0].ends_with("valid.rs"), + "The only fingerprinted file should be valid.rs" + ); + + drop(temp_dir); + } +} diff --git a/crates/vespera_macro/src/file_utils.rs b/crates/vespera_macro/src/file_utils.rs index b5981249..4c00c57b 100644 --- a/crates/vespera_macro/src/file_utils.rs +++ b/crates/vespera_macro/src/file_utils.rs @@ -4,17 +4,46 @@ use std::{ }; pub fn collect_files(folder_path: &Path) -> io::Result> { + Ok(collect_files_with_mtimes(folder_path)? + .into_iter() + .map(|(path, _)| path) + .collect()) +} + +/// Recursively collect files together with their mtimes (secs since +/// `UNIX_EPOCH`; `0` when unavailable). +/// +/// One walk serves both route discovery and cache fingerprinting — +/// previously the folder was walked twice and every file paid an +/// extra `fs::metadata` syscall on top of the directory-entry data +/// the OS already returned. +pub fn collect_files_with_mtimes(folder_path: &Path) -> io::Result> { let mut files = Vec::new(); + collect_with_mtimes_into(folder_path, &mut files)?; + Ok(files) +} + +fn collect_with_mtimes_into(folder_path: &Path, out: &mut Vec<(PathBuf, u64)>) -> io::Result<()> { for entry in std::fs::read_dir(folder_path)? { let entry = entry?; + let file_type = entry.file_type()?; let path = entry.path(); - if path.is_file() { - files.push(folder_path.join(path)); - } else if path.is_dir() { - files.extend(collect_files(&folder_path.join(&path))?); + if file_type.is_file() { + let mtime = entry + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .map_or(0, |t| { + t.duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + }); + out.push((path, mtime)); + } else if file_type.is_dir() { + collect_with_mtimes_into(&path, out)?; } } - Ok(files) + Ok(()) } pub fn file_to_segments(file: &Path, base_path: &Path) -> Vec { diff --git a/crates/vespera_macro/src/lib.rs b/crates/vespera_macro/src/lib.rs index f19f6a77..464d4f3d 100644 --- a/crates/vespera_macro/src/lib.rs +++ b/crates/vespera_macro/src/lib.rs @@ -120,6 +120,8 @@ pub fn cron(attr: TokenStream, item: TokenStream) -> TokenStream { #[cfg(not(tarpaulin_include))] #[proc_macro_derive(Schema, attributes(schema, serde))] pub fn derive_schema(input: TokenStream) -> TokenStream { + schema_macro::file_cache::bump_epoch(); + let input = syn::parse_macro_input!(input as syn::DeriveInput); let (metadata, expanded) = schema_impl::process_derive_schema(&input); let name = metadata.name.clone(); @@ -226,9 +228,10 @@ pub fn derive_multipart(input: TokenStream) -> TokenStream { #[cfg(not(tarpaulin_include))] #[proc_macro] pub fn schema(input: TokenStream) -> TokenStream { + schema_macro::file_cache::bump_epoch(); + let input = syn::parse_macro_input!(input as schema_macro::SchemaInput); - // Get stored schemas let storage = SCHEMA_STORAGE .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -296,10 +299,11 @@ pub fn schema(input: TokenStream) -> TokenStream { #[cfg(not(tarpaulin_include))] #[proc_macro] pub fn schema_type(input: TokenStream) -> TokenStream { + schema_macro::file_cache::bump_epoch(); + let input = syn::parse_macro_input!(input as schema_macro::SchemaTypeInput); let ignore_schema = input.ignore_schema; - // Get stored schemas and generate code let (tokens, generated_metadata) = { let storage = SCHEMA_STORAGE .lock() @@ -337,6 +341,8 @@ pub fn schema_type(input: TokenStream) -> TokenStream { #[cfg(not(tarpaulin_include))] #[proc_macro] pub fn vespera(input: TokenStream) -> TokenStream { + schema_macro::file_cache::bump_epoch(); + let input = syn::parse_macro_input!(input as AutoRouterInput); let processed = process_vespera_input(input); let schema_storage = SCHEMA_STORAGE @@ -377,6 +383,8 @@ pub fn vespera(input: TokenStream) -> TokenStream { #[cfg(not(tarpaulin_include))] #[proc_macro] pub fn export_app(input: TokenStream) -> TokenStream { + schema_macro::file_cache::bump_epoch(); + let ExportAppInput { name, dir } = syn::parse_macro_input!(input as ExportAppInput); let folder_name = dir .map(|d| d.value()) diff --git a/crates/vespera_macro/src/metadata.rs b/crates/vespera_macro/src/metadata.rs index 414816ad..10b176b3 100644 --- a/crates/vespera_macro/src/metadata.rs +++ b/crates/vespera_macro/src/metadata.rs @@ -17,8 +17,7 @@ pub struct RouteMetadata { pub module_path: String, /// File path pub file_path: String, - /// Function signature (as string for serialization) - pub signature: String, + /// Additional error status codes from `error_status` attribute #[serde(skip_serializing_if = "Option::is_none")] pub error_status: Option>, diff --git a/crates/vespera_macro/src/multipart_impl.rs b/crates/vespera_macro/src/multipart_impl.rs deleted file mode 100644 index 923669f9..00000000 --- a/crates/vespera_macro/src/multipart_impl.rs +++ /dev/null @@ -1,1172 +0,0 @@ -//! Vespera's `Multipart` derive macro implementation. -//! -//! This is a re-implementation of `axum_typed_multipart`'s derive macro that -//! natively supports `#[serde(rename_all)]` and `#[serde(rename)]` attributes -//! for field name resolution in multipart form data. -//! -//! ## Why? -//! -//! `axum_typed_multipart`'s derive macro only reads `#[try_from_multipart(rename_all)]` -//! and ignores `#[serde(rename_all)]`. This causes a mismatch: the OpenAPI spec -//! (generated by `Schema` derive) shows camelCase field names, but the runtime -//! multipart parser expects snake_case Rust field names. -//! -//! ## Field Name Resolution Priority -//! -//! 1. `#[form_data(field_name = "...")]` — explicit override (highest priority) -//! 2. `#[serde(rename = "...")]` — serde field rename -//! 3. `#[serde(rename_all = "...")]` or `#[try_from_multipart(rename_all = "...")]` applied to Rust name -//! 4. Rust field name as-is (lowest priority) - -use proc_macro2::TokenStream; -use quote::quote; -use syn::{DeriveInput, Fields, Type}; - -use crate::parser::{extract_default, extract_field_rename, extract_rename_all, rename_field}; - -/// Collected codegen fragments for each struct field. -struct FieldCodegen<'a> { - declarations: Vec, - assignments: Vec, - post_loop: Vec, - idents: Vec<&'a syn::Ident>, -} - -/// How a missing field should be handled. -enum DefaultKind { - /// No default — field is required; emit `MissingField` error. - None, - /// Use `Default::default()` — from `#[serde(default)]` or `#[form_data(default)]`. - Trait, - /// Call a custom function — from `#[serde(default = "path::to::fn")]`. - Function(String), -} - -/// Process all named fields into codegen fragments. -fn process_fields<'a>( - fields: impl Iterator, - rename_all: Option<&str>, - strict: bool, - struct_default: bool, -) -> FieldCodegen<'a> { - let mut cg = FieldCodegen { - declarations: Vec::new(), - assignments: Vec::new(), - post_loop: Vec::new(), - idents: Vec::new(), - }; - - for field in fields { - let ident = field.ident.as_ref().unwrap(); - let ty = &field.ty; - let is_vec = is_vec_type(ty); - let is_option = is_option_type(ty); - let field_name = resolve_field_name(ident, &field.attrs, rename_all); - let limit_tokens = extract_limit_tokens(&field.attrs); - let default_kind = resolve_default_kind(&field.attrs, struct_default); - - // The concrete type for TryFromFieldWithState turbofish. For Option - // and Vec the derive wraps the parsed value, so the trait Self is T. - let parse_ty = if is_option || is_vec { - extract_inner_generic(ty).unwrap_or_else(|| ty.clone()) - } else { - ty.clone() - }; - - // Variable declaration - if is_vec { - cg.declarations - .push(quote! { let mut #ident: #ty = std::vec::Vec::new(); }); - } else if is_option { - cg.declarations - .push(quote! { let mut #ident: #ty = std::option::Option::None; }); - } else { - cg.declarations.push( - quote! { let mut #ident: std::option::Option<#ty> = std::option::Option::None; }, - ); - } - - // Field value parsing — explicit turbofish types are required because - // RPITIT opaque return types prevent the compiler from inferring - // `TryFromFieldWithState::Self` through `.await`. - let try_from_call = quote! { <#parse_ty as vespera::multipart::TryFromFieldWithState<__VesperaS__>>::try_from_field_with_state }; - let parse_value = quote! { #try_from_call(__field__, #limit_tokens, __state__).await? }; - - let assignment = if is_vec { - quote! { #ident.push(#parse_value); } - } else if strict { - let set_value = quote! { #ident = std::option::Option::Some(#parse_value) }; - let dup_err = quote! { return std::result::Result::Err(vespera::multipart::TypedMultipartError::DuplicateField { field_name: std::string::String::from(#field_name) }) }; - quote! { if #ident.is_none() { #set_value ; } else { #dup_err ; } } - } else { - quote! { #ident = std::option::Option::Some(#parse_value); } - }; - - let field_match = quote! { if __field_name__ == #field_name { #assignment } }; - cg.assignments.push(field_match); - - // Post-loop: required field checks / defaults - if !is_option && !is_vec { - match &default_kind { - DefaultKind::Trait => { - cg.post_loop.push(quote! { - let #ident: #ty = #ident.unwrap_or_default(); - }); - } - DefaultKind::Function(fn_path) => { - let path: syn::ExprPath = - syn::parse_str(fn_path).expect("invalid default function path"); - cg.post_loop.push(quote! { - let #ident: #ty = #ident.unwrap_or_else(#path); - }); - } - DefaultKind::None => { - cg.post_loop.push(quote! { - let #ident = #ident.ok_or( - vespera::multipart::TypedMultipartError::MissingField { - field_name: std::string::String::from(#field_name) - } - )?; - }); - } - } - } - - cg.idents.push(ident); - } - - cg -} - -/// Process the `#[derive(TryFromMultipart)]` macro input. -pub fn process_derive(input: &DeriveInput) -> TokenStream { - let struct_name = &input.ident; - let rename_all = extract_rename_all(&input.attrs); - let strict = extract_strict(&input.attrs); - let struct_default = extract_struct_default(&input.attrs); - - let fields = match &input.data { - syn::Data::Struct(data) => match &data.fields { - Fields::Named(named) => &named.named, - _ => { - return syn::Error::new_spanned( - &input.ident, - "Multipart only supports structs with named fields", - ) - .to_compile_error(); - } - }, - _ => { - return syn::Error::new_spanned( - &input.ident, - "Multipart can only be derived for structs", - ) - .to_compile_error(); - } - }; - - let mut cg = process_fields(fields.iter(), rename_all.as_deref(), strict, struct_default); - - if strict { - cg.assignments.push(quote! { - { - return std::result::Result::Err( - vespera::multipart::TypedMultipartError::UnknownField { - field_name: __field_name__ - } - ); - } - }); - } - - let missing_name_fallback = if strict { - quote! { - return std::result::Result::Err( - vespera::multipart::TypedMultipartError::NamelessField - ) - } - } else { - quote! { continue } - }; - - let FieldCodegen { - declarations, - assignments, - post_loop, - idents, - .. - } = &cg; - - quote! { - impl<__VesperaS__: Send + Sync> vespera::multipart::TryFromMultipartWithState<__VesperaS__> for #struct_name { - async fn try_from_multipart_with_state( - __multipart__: &mut vespera::axum::extract::Multipart, - __state__: &__VesperaS__, - ) -> std::result::Result { - #(#declarations)* - - while let std::option::Option::Some(__field__) = __multipart__ - .next_field().await - .map_err(vespera::multipart::TypedMultipartError::from)? { - let __field_name__ = match __field__.name() { - | std::option::Option::Some("") - | std::option::Option::None => #missing_name_fallback, - | std::option::Option::Some(__name__) => __name__.to_string(), - }; - - #(#assignments) else * - } - - #(#post_loop)* - - std::result::Result::Ok(Self { #(#idents),* }) - } - } - } -} - -// ─── Field Name Resolution ────────────────────────────────────────────────── - -/// Resolve the multipart field name using serde + form_data attributes. -/// -/// Priority: -/// 1. `#[form_data(field_name = "...")]` -/// 2. `#[serde(rename = "...")]` -/// 3. struct-level `rename_all` applied to Rust field name -/// 4. Rust field name as-is -fn resolve_field_name( - ident: &syn::Ident, - attrs: &[syn::Attribute], - rename_all: Option<&str>, -) -> String { - // 1. Explicit form_data override - if let Some(name) = extract_form_data_field_name(attrs) { - return name; - } - - // 2. Serde field rename - if let Some(name) = extract_field_rename(attrs) { - return name; - } - - // 3. Apply rename_all to Rust field name - let rust_name = strip_raw_prefix(&ident.to_string()); - rename_field(&rust_name, rename_all) -} - -// ─── Attribute Extraction ─────────────────────────────────────────────────── - -/// Extract `field_name` from `#[form_data(field_name = "...")]`. -fn extract_form_data_field_name(attrs: &[syn::Attribute]) -> Option { - for attr in attrs { - if attr.path().is_ident("form_data") { - let mut found = None; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("field_name") - && let Ok(value) = meta.value() - && let Ok(lit) = value.parse::() - { - found = Some(lit.value()); - } - Ok(()) - }); - if found.is_some() { - return found; - } - } - } - None -} - -/// Extract `strict` flag from `#[try_from_multipart(strict)]`. -fn extract_strict(attrs: &[syn::Attribute]) -> bool { - for attr in attrs { - if attr.path().is_ident("try_from_multipart") { - let mut strict = false; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("strict") { - strict = true; - } - Ok(()) - }); - if strict { - return true; - } - } - } - false -} - -/// Extract `limit` from `#[form_data(limit = "10MiB")]` and emit as `Option` tokens. -fn extract_limit_tokens(attrs: &[syn::Attribute]) -> TokenStream { - for attr in attrs { - if attr.path().is_ident("form_data") { - let mut limit_str = None; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("limit") - && let Ok(value) = meta.value() - && let Ok(lit) = value.parse::() - { - limit_str = Some(lit.value()); - } - Ok(()) - }); - if let Some(s) = limit_str { - if s == "unlimited" { - return quote! { std::option::Option::None }; - } - if let Some(bytes) = parse_byte_unit(&s) { - return quote! { std::option::Option::Some(#bytes) }; - } - } - } - } - // Default: no limit (None) - quote! { std::option::Option::None } -} - -/// Resolve the default behavior for a field. -/// -/// Priority: -/// 1. `#[form_data(default)]` — explicit form_data override (bare default) -/// 2. `#[serde(default)]` — bare default via `Default::default()` -/// 3. `#[serde(default = "fn_path")]` — custom default function -/// 4. Struct-level `#[serde(default)]` — all fields get `Default::default()` -/// 5. No default — field is required -fn resolve_default_kind(attrs: &[syn::Attribute], struct_default: bool) -> DefaultKind { - // 1. Check #[form_data(default)] - if extract_form_data_default(attrs) { - return DefaultKind::Trait; - } - - // 2-3. Check #[serde(default)] or #[serde(default = "fn")] - if let Some(serde_default) = extract_default(attrs) { - return serde_default.map_or(DefaultKind::Trait, DefaultKind::Function); - } - - // 4. Struct-level #[serde(default)] - if struct_default { - return DefaultKind::Trait; - } - - DefaultKind::None -} - -/// Extract `default` flag from `#[form_data(default)]`. -fn extract_form_data_default(attrs: &[syn::Attribute]) -> bool { - for attr in attrs { - if attr.path().is_ident("form_data") { - let mut has_default = false; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("default") { - has_default = true; - } - Ok(()) - }); - if has_default { - return true; - } - } - } - false -} - -/// Check if the struct has `#[serde(default)]` at the struct level. -fn extract_struct_default(attrs: &[syn::Attribute]) -> bool { - // Reuse extract_default — if it returns Some(None), it's bare #[serde(default)] - // For struct-level, we only support bare default (no custom function) - extract_default(attrs).is_some() -} - -// ─── Type Utilities ───────────────────────────────────────────────────────── - -/// Extract the first generic type argument from a type like `Option` or `Vec`. -fn extract_inner_generic(ty: &Type) -> Option { - let Type::Path(type_path) = ty else { - return None; - }; - let segment = type_path.path.segments.last()?; - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner)) = args.args.first() - { - return Some(inner.clone()); - } - None -} - -/// Check if a type matches `Option`. -fn is_option_type(ty: &Type) -> bool { - matches_type_name( - ty, - &["Option", "std::option::Option", "core::option::Option"], - ) -} - -/// Check if a type matches `Vec`. -fn is_vec_type(ty: &Type) -> bool { - matches_type_name(ty, &["Vec", "std::vec::Vec"]) -} - -/// Check if a type's path matches any of the given names. -fn matches_type_name(ty: &Type, names: &[&str]) -> bool { - let path = match ty { - Type::Path(type_path) if type_path.qself.is_none() => &type_path.path, - _ => return false, - }; - let sig = path - .segments - .iter() - .map(|s| s.ident.to_string()) - .collect::>() - .join("::"); - names.contains(&sig.as_str()) -} - -/// Strip leading `r#` from raw identifiers. -fn strip_raw_prefix(s: &str) -> String { - s.strip_prefix("r#").unwrap_or(s).to_string() -} - -// ─── Byte Unit Parser ─────────────────────────────────────────────────────── - -/// Parse a human-readable byte unit string into bytes. -/// -/// Supports: `"10MiB"`, `"1GB"`, `"500KB"`, `"1024"`, `"unlimited"`. -fn parse_byte_unit(s: &str) -> Option { - let s = s.trim(); - - // Binary and decimal suffixes, longest first to avoid prefix collisions - let suffixes: &[(&str, usize)] = &[ - ("GiB", 1024 * 1024 * 1024), - ("MiB", 1024 * 1024), - ("KiB", 1024), - ("GB", 1_000_000_000), - ("MB", 1_000_000), - ("KB", 1_000), - ("B", 1), - ]; - - for (suffix, multiplier) in suffixes { - if let Some(num_str) = s.strip_suffix(suffix) { - return num_str.trim().parse::().ok().map(|n| n * multiplier); - } - } - - // Plain number (bytes) - s.parse::().ok() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_byte_unit() { - assert_eq!(parse_byte_unit("10MiB"), Some(10 * 1024 * 1024)); - assert_eq!(parse_byte_unit("50MiB"), Some(50 * 1024 * 1024)); - assert_eq!(parse_byte_unit("1GB"), Some(1_000_000_000)); - assert_eq!(parse_byte_unit("500KB"), Some(500_000)); - assert_eq!(parse_byte_unit("1024"), Some(1024)); - assert_eq!(parse_byte_unit("0"), Some(0)); - assert_eq!(parse_byte_unit("invalid"), None); - } - - #[test] - fn test_parse_byte_unit_all_suffixes() { - assert_eq!(parse_byte_unit("1GiB"), Some(1024 * 1024 * 1024)); - assert_eq!(parse_byte_unit("2KiB"), Some(2 * 1024)); - assert_eq!(parse_byte_unit("3MB"), Some(3_000_000)); - assert_eq!(parse_byte_unit("4B"), Some(4)); - assert_eq!(parse_byte_unit(" 5MiB "), Some(5 * 1024 * 1024)); - } - - #[test] - fn test_strip_raw_prefix() { - assert_eq!(strip_raw_prefix("r#type"), "type"); - assert_eq!(strip_raw_prefix("normal"), "normal"); - } - - // ─── extract_inner_generic ────────────────────────────────────────── - - #[test] - fn test_extract_inner_generic_option() { - let ty: syn::Type = syn::parse_str("Option").unwrap(); - let inner = extract_inner_generic(&ty).unwrap(); - assert_eq!(quote!(#inner).to_string(), "String"); - } - - #[test] - fn test_extract_inner_generic_vec() { - let ty: syn::Type = syn::parse_str("Vec").unwrap(); - let inner = extract_inner_generic(&ty).unwrap(); - assert_eq!(quote!(#inner).to_string(), "i32"); - } - - #[test] - fn test_extract_inner_generic_no_generics() { - let ty: syn::Type = syn::parse_str("String").unwrap(); - assert!(extract_inner_generic(&ty).is_none()); - } - - #[test] - fn test_extract_inner_generic_non_path() { - let ty: syn::Type = syn::parse_str("(i32, String)").unwrap(); - assert!(extract_inner_generic(&ty).is_none()); - } - - // ─── is_option_type / is_vec_type ─────────────────────────────────── - - #[test] - fn test_is_option_type() { - let ty: syn::Type = syn::parse_str("Option").unwrap(); - assert!(is_option_type(&ty)); - - let ty: syn::Type = syn::parse_str("std::option::Option").unwrap(); - assert!(is_option_type(&ty)); - - let ty: syn::Type = syn::parse_str("Vec").unwrap(); - assert!(!is_option_type(&ty)); - - let ty: syn::Type = syn::parse_str("String").unwrap(); - assert!(!is_option_type(&ty)); - } - - #[test] - fn test_is_vec_type() { - let ty: syn::Type = syn::parse_str("Vec").unwrap(); - assert!(is_vec_type(&ty)); - - let ty: syn::Type = syn::parse_str("std::vec::Vec").unwrap(); - assert!(is_vec_type(&ty)); - - let ty: syn::Type = syn::parse_str("Option").unwrap(); - assert!(!is_vec_type(&ty)); - - let ty: syn::Type = syn::parse_str("String").unwrap(); - assert!(!is_vec_type(&ty)); - } - - // ─── matches_type_name ────────────────────────────────────────────── - - #[test] - fn test_matches_type_name_simple() { - let ty: syn::Type = syn::parse_str("Option").unwrap(); - assert!(matches_type_name(&ty, &["Option"])); - assert!(!matches_type_name(&ty, &["Vec"])); - } - - #[test] - fn test_matches_type_name_qualified() { - let ty: syn::Type = syn::parse_str("std::option::Option").unwrap(); - assert!(matches_type_name(&ty, &["std::option::Option"])); - assert!(!matches_type_name(&ty, &["Option"])); // qualified doesn't match simple - } - - #[test] - fn test_matches_type_name_non_path() { - let ty: syn::Type = syn::parse_str("(i32, String)").unwrap(); - assert!(!matches_type_name(&ty, &["Option", "Vec"])); - } - - // ─── extract_form_data_field_name ─────────────────────────────────── - - fn parse_field(code: &str) -> syn::Field { - let input: syn::DeriveInput = syn::parse_str(&format!("struct T {{ {code} }}")).unwrap(); - match &input.data { - syn::Data::Struct(s) => match &s.fields { - Fields::Named(n) => n.named.first().unwrap().clone(), - _ => unreachable!(), - }, - _ => unreachable!(), - } - } - - fn parse_attrs(code: &str) -> Vec { - parse_field(code).attrs - } - - #[test] - fn test_extract_form_data_field_name_present() { - let attrs = parse_attrs(r#"#[form_data(field_name = "custom")] pub x: String"#); - assert_eq!( - extract_form_data_field_name(&attrs), - Some("custom".to_string()) - ); - } - - #[test] - fn test_extract_form_data_field_name_absent() { - let attrs = parse_attrs("pub x: String"); - assert_eq!(extract_form_data_field_name(&attrs), None); - } - - #[test] - fn test_extract_form_data_field_name_other_form_data_attr() { - let attrs = parse_attrs(r#"#[form_data(limit = "100")] pub x: String"#); - assert_eq!(extract_form_data_field_name(&attrs), None); - } - - // ─── extract_strict ───────────────────────────────────────────────── - - fn parse_struct_attrs(code: &str) -> Vec { - let input: syn::DeriveInput = syn::parse_str(code).unwrap(); - input.attrs - } - - #[test] - fn test_extract_strict_present() { - let attrs = parse_struct_attrs("#[try_from_multipart(strict)] struct T { }"); - assert!(extract_strict(&attrs)); - } - - #[test] - fn test_extract_strict_absent() { - let attrs = parse_struct_attrs("struct T { }"); - assert!(!extract_strict(&attrs)); - } - - #[test] - fn test_extract_strict_other_attr() { - let attrs = - parse_struct_attrs("#[try_from_multipart(rename_all = \"camelCase\")] struct T { }"); - assert!(!extract_strict(&attrs)); - } - - // ─── extract_form_data_default ────────────────────────────────────── - - #[test] - fn test_extract_form_data_default_present() { - let attrs = parse_attrs("#[form_data(default)] pub x: i32"); - assert!(extract_form_data_default(&attrs)); - } - - #[test] - fn test_extract_form_data_default_absent() { - let attrs = parse_attrs("pub x: i32"); - assert!(!extract_form_data_default(&attrs)); - } - - #[test] - fn test_extract_form_data_default_other_form_data() { - let attrs = parse_attrs(r#"#[form_data(limit = "100")] pub x: i32"#); - assert!(!extract_form_data_default(&attrs)); - } - - // ─── extract_struct_default ───────────────────────────────────────── - - #[test] - fn test_extract_struct_default_present() { - let attrs = parse_struct_attrs("#[serde(default)] struct T { }"); - assert!(extract_struct_default(&attrs)); - } - - #[test] - fn test_extract_struct_default_absent() { - let attrs = parse_struct_attrs("struct T { }"); - assert!(!extract_struct_default(&attrs)); - } - - // ─── resolve_default_kind ─────────────────────────────────────────── - - #[test] - fn test_resolve_default_kind_none() { - let attrs = parse_attrs("pub x: i32"); - assert!(matches!( - resolve_default_kind(&attrs, false), - DefaultKind::None - )); - } - - #[test] - fn test_resolve_default_kind_serde_default() { - let attrs = parse_attrs("#[serde(default)] pub x: i32"); - assert!(matches!( - resolve_default_kind(&attrs, false), - DefaultKind::Trait - )); - } - - #[test] - fn test_resolve_default_kind_serde_default_fn() { - let attrs = parse_attrs(r#"#[serde(default = "my_fn")] pub x: i32"#); - assert!( - matches!(resolve_default_kind(&attrs, false), DefaultKind::Function(ref f) if f == "my_fn") - ); - } - - #[test] - fn test_resolve_default_kind_form_data_default() { - let attrs = parse_attrs("#[form_data(default)] pub x: i32"); - assert!(matches!( - resolve_default_kind(&attrs, false), - DefaultKind::Trait - )); - } - - #[test] - fn test_resolve_default_kind_struct_level() { - let attrs = parse_attrs("pub x: i32"); - assert!(matches!( - resolve_default_kind(&attrs, true), - DefaultKind::Trait - )); - } - - #[test] - fn test_resolve_default_kind_form_data_overrides_struct_default() { - // form_data(default) takes priority, but result is the same (Trait) - let attrs = parse_attrs("#[form_data(default)] pub x: i32"); - assert!(matches!( - resolve_default_kind(&attrs, true), - DefaultKind::Trait - )); - } - - // ─── resolve_field_name ───────────────────────────────────────────── - - #[test] - fn test_resolve_field_name_plain() { - let field = parse_field("pub my_field: String"); - let name = resolve_field_name(field.ident.as_ref().unwrap(), &field.attrs, None); - assert_eq!(name, "my_field"); - } - - #[test] - fn test_resolve_field_name_rename_all() { - let field = parse_field("pub my_field: String"); - let name = resolve_field_name( - field.ident.as_ref().unwrap(), - &field.attrs, - Some("camelCase"), - ); - assert_eq!(name, "myField"); - } - - #[test] - fn test_resolve_field_name_serde_rename() { - let field = parse_field(r#"#[serde(rename = "custom")] pub my_field: String"#); - let name = resolve_field_name( - field.ident.as_ref().unwrap(), - &field.attrs, - Some("camelCase"), - ); - assert_eq!(name, "custom"); // explicit rename beats rename_all - } - - #[test] - fn test_resolve_field_name_form_data_field_name() { - let field = parse_field( - r#"#[form_data(field_name = "override")] #[serde(rename = "serde_name")] pub my_field: String"#, - ); - let name = resolve_field_name( - field.ident.as_ref().unwrap(), - &field.attrs, - Some("camelCase"), - ); - assert_eq!(name, "override"); // form_data field_name beats everything - } - - // ─── extract_limit_tokens ─────────────────────────────────────────── - - #[test] - fn test_extract_limit_tokens_none() { - let attrs = parse_attrs("pub x: String"); - let tokens = extract_limit_tokens(&attrs); - assert_eq!(tokens.to_string(), "std :: option :: Option :: None"); - } - - #[test] - fn test_extract_limit_tokens_with_value() { - let attrs = parse_attrs(r#"#[form_data(limit = "100")] pub x: String"#); - let tokens = extract_limit_tokens(&attrs); - assert_eq!( - tokens.to_string(), - "std :: option :: Option :: Some (100usize)" - ); - } - - #[test] - fn test_extract_limit_tokens_unlimited() { - let attrs = parse_attrs(r#"#[form_data(limit = "unlimited")] pub x: String"#); - let tokens = extract_limit_tokens(&attrs); - assert_eq!(tokens.to_string(), "std :: option :: Option :: None"); - } - - #[test] - fn test_extract_limit_tokens_mib() { - let attrs = parse_attrs(r#"#[form_data(limit = "10MiB")] pub x: String"#); - let tokens = extract_limit_tokens(&attrs); - let expected = 10 * 1024 * 1024; - assert_eq!( - tokens.to_string(), - format!("std :: option :: Option :: Some ({expected}usize)") - ); - } - - // ─── process_derive ───────────────────────────────────────────────── - - #[test] - fn test_process_derive_basic_struct() { - let input: syn::DeriveInput = - syn::parse_str("struct MyForm { pub name: String, pub age: i32 }").unwrap(); - let tokens = process_derive(&input); - let code = tokens.to_string(); - assert!( - code.contains("TryFromMultipartWithState"), - "should generate trait impl" - ); - assert!(code.contains("MyForm"), "should reference the struct name"); - assert!(code.contains("\"name\""), "should reference field name"); - assert!(code.contains("\"age\""), "should reference field name"); - } - - #[test] - fn test_process_derive_with_option_field() { - let input: syn::DeriveInput = - syn::parse_str("struct MyForm { pub name: String, pub bio: Option }").unwrap(); - let tokens = process_derive(&input); - let code = tokens.to_string(); - assert!(code.contains("TryFromMultipartWithState")); - // Option fields get initialized to None, no MissingField check - assert!(code.contains("Option :: None")); - } - - #[test] - fn test_process_derive_with_vec_field() { - let input: syn::DeriveInput = - syn::parse_str("struct MyForm { pub name: String, pub tags: Vec }").unwrap(); - let tokens = process_derive(&input); - let code = tokens.to_string(); - assert!( - code.contains("Vec :: new"), - "Vec fields should be initialized with Vec::new()" - ); - assert!(code.contains("push"), "Vec fields should use push()"); - } - - #[test] - fn test_process_derive_strict_mode() { - let input: syn::DeriveInput = - syn::parse_str("#[try_from_multipart(strict)] struct MyForm { pub name: String }") - .unwrap(); - let tokens = process_derive(&input); - let code = tokens.to_string(); - assert!( - code.contains("DuplicateField"), - "strict mode should check for duplicates" - ); - assert!( - code.contains("UnknownField"), - "strict mode should reject unknown fields" - ); - assert!( - code.contains("NamelessField"), - "strict mode should reject nameless fields" - ); - } - - #[test] - fn test_process_derive_with_rename_all() { - let input: syn::DeriveInput = syn::parse_str( - r#"#[serde(rename_all = "camelCase")] struct MyForm { pub user_name: String }"#, - ) - .unwrap(); - let tokens = process_derive(&input); - let code = tokens.to_string(); - assert!( - code.contains("\"userName\""), - "rename_all should convert to camelCase" - ); - } - - #[test] - fn test_process_derive_with_serde_default() { - let input: syn::DeriveInput = - syn::parse_str("#[serde(default)] struct MyForm { pub count: i32 }").unwrap(); - let tokens = process_derive(&input); - let code = tokens.to_string(); - assert!( - code.contains("unwrap_or_default"), - "struct-level default should use unwrap_or_default" - ); - } - - #[test] - fn test_process_derive_with_field_default_fn() { - let input: syn::DeriveInput = - syn::parse_str(r#"struct MyForm { #[serde(default = "my_default")] pub val: String }"#) - .unwrap(); - let tokens = process_derive(&input); - let code = tokens.to_string(); - assert!( - code.contains("unwrap_or_else"), - "field default fn should use unwrap_or_else" - ); - assert!( - code.contains("my_default"), - "should reference the default function" - ); - } - - #[test] - fn test_process_derive_non_struct_errors() { - let input: syn::DeriveInput = syn::parse_str("enum Foo { A, B }").unwrap(); - let tokens = process_derive(&input); - let code = tokens.to_string(); - assert!( - code.contains("compile_error"), - "enums should produce compile error" - ); - } - - #[test] - fn test_process_derive_tuple_struct_errors() { - let input: syn::DeriveInput = syn::parse_str("struct Foo(String, i32);").unwrap(); - let tokens = process_derive(&input); - let code = tokens.to_string(); - assert!( - code.contains("compile_error"), - "tuple structs should produce compile error" - ); - } - - #[test] - fn test_process_derive_form_data_field_name() { - let input: syn::DeriveInput = syn::parse_str( - r#"struct MyForm { #[form_data(field_name = "custom")] pub data: String }"#, - ) - .unwrap(); - let tokens = process_derive(&input); - let code = tokens.to_string(); - assert!( - code.contains("\"custom\""), - "form_data field_name should be used" - ); - } - - #[test] - fn test_process_derive_form_data_default() { - let input: syn::DeriveInput = - syn::parse_str("struct MyForm { #[form_data(default)] pub count: i32 }").unwrap(); - let tokens = process_derive(&input); - let code = tokens.to_string(); - assert!( - code.contains("unwrap_or_default"), - "form_data(default) should use unwrap_or_default" - ); - } - - #[test] - fn test_process_derive_non_strict_no_duplicate_check() { - let input: syn::DeriveInput = syn::parse_str("struct MyForm { pub name: String }").unwrap(); - let tokens = process_derive(&input); - let code = tokens.to_string(); - assert!( - !code.contains("DuplicateField"), - "non-strict should not check for duplicates" - ); - assert!( - !code.contains("UnknownField"), - "non-strict should not check for unknown fields" - ); - } - - // ─── process_fields direct tests ──────────────────────────────────── - // - // Exercise process_fields directly to ensure quote! token construction - // for each branch (parse_value, strict assignment, field matching) is - // fully traced by the coverage tool. - - fn parse_fields_from(code: &str) -> syn::DeriveInput { - syn::parse_str(code).unwrap() - } - - fn get_named_fields( - input: &syn::DeriveInput, - ) -> &syn::punctuated::Punctuated { - match &input.data { - syn::Data::Struct(s) => match &s.fields { - Fields::Named(n) => &n.named, - _ => panic!("expected named fields"), - }, - _ => panic!("expected struct"), - } - } - - #[test] - fn test_process_fields_required_field_generates_parse_value() { - let input = parse_fields_from("struct T { pub name: String }"); - let fields = get_named_fields(&input); - let cg = process_fields(fields.iter(), None, false, false); - - // parse_value is interpolated into each assignment - let assignment_code = cg - .assignments - .iter() - .map(ToString::to_string) - .collect::>() - .join(" "); - assert!( - assignment_code.contains("TryFromFieldWithState"), - "parse_value should contain turbofish call" - ); - assert!( - assignment_code.contains("try_from_field_with_state"), - "should call try_from_field_with_state" - ); - assert!( - assignment_code.contains("\"name\""), - "should match on field name" - ); - - // post_loop should have MissingField check for required fields - let post_code = cg - .post_loop - .iter() - .map(ToString::to_string) - .collect::>() - .join(" "); - assert!( - post_code.contains("MissingField"), - "required field should have MissingField check" - ); - } - - #[test] - fn test_process_fields_strict_required_field_generates_duplicate_check() { - let input = parse_fields_from("struct T { pub name: String, pub age: i32 }"); - let fields = get_named_fields(&input); - let cg = process_fields(fields.iter(), None, true, false); - - // strict mode: assignments should contain is_none + DuplicateField check - let assignment_code = cg - .assignments - .iter() - .map(ToString::to_string) - .collect::>() - .join(" "); - assert!( - assignment_code.contains("is_none"), - "strict assignment should check is_none" - ); - assert!( - assignment_code.contains("DuplicateField"), - "strict assignment should have DuplicateField" - ); - assert!( - assignment_code.contains("\"name\""), - "should match name field" - ); - assert!( - assignment_code.contains("\"age\""), - "should match age field" - ); - - // Both fields should have parse_value with turbofish - assert!( - assignment_code.contains("TryFromFieldWithState"), - "should contain turbofish" - ); - } - - #[test] - fn test_process_fields_vec_field_generates_push() { - let input = parse_fields_from("struct T { pub tags: Vec }"); - let fields = get_named_fields(&input); - let cg = process_fields(fields.iter(), None, false, false); - - let decl_code = cg - .declarations - .iter() - .map(ToString::to_string) - .collect::>() - .join(" "); - assert!( - decl_code.contains("Vec :: new"), - "Vec field should initialize with Vec::new()" - ); - - let assignment_code = cg - .assignments - .iter() - .map(ToString::to_string) - .collect::>() - .join(" "); - assert!( - assignment_code.contains("push"), - "Vec field assignment should use push" - ); - - // Vec fields should NOT have post_loop (no MissingField check) - assert!( - cg.post_loop.is_empty(), - "Vec fields should not have post-loop checks" - ); - } - - #[test] - fn test_process_fields_option_field_no_missing_check() { - let input = parse_fields_from("struct T { pub bio: Option }"); - let fields = get_named_fields(&input); - let cg = process_fields(fields.iter(), None, false, false); - - let decl_code = cg - .declarations - .iter() - .map(ToString::to_string) - .collect::>() - .join(" "); - assert!( - decl_code.contains("Option :: None"), - "Option field should initialize to None" - ); - - // Option fields should NOT have post_loop - assert!( - cg.post_loop.is_empty(), - "Option fields should not have post-loop checks" - ); - } - - #[test] - fn test_process_fields_strict_vec_field_uses_push_not_duplicate() { - let input = parse_fields_from("struct T { pub tags: Vec }"); - let fields = get_named_fields(&input); - let cg = process_fields(fields.iter(), None, true, false); - - // Even in strict mode, Vec fields use push (not duplicate check) - let assignment_code = cg - .assignments - .iter() - .map(ToString::to_string) - .collect::>() - .join(" "); - assert!( - assignment_code.contains("push"), - "Vec in strict mode should still use push" - ); - assert!( - !assignment_code.contains("DuplicateField"), - "Vec should not have duplicate check" - ); - } - - #[test] - fn test_process_fields_mixed_types() { - let input = parse_fields_from( - "struct T { pub name: String, pub tags: Vec, pub bio: Option }", - ); - let fields = get_named_fields(&input); - let cg = process_fields(fields.iter(), None, false, false); - - assert_eq!(cg.idents.len(), 3, "should have 3 fields"); - assert_eq!(cg.declarations.len(), 3, "should have 3 declarations"); - assert_eq!(cg.assignments.len(), 3, "should have 3 assignments"); - // Only 'name' is required (not Option, not Vec), so 1 post_loop - assert_eq!( - cg.post_loop.len(), - 1, - "only required field should have post-loop" - ); - } -} diff --git a/crates/vespera_macro/src/multipart_impl/attrs.rs b/crates/vespera_macro/src/multipart_impl/attrs.rs new file mode 100644 index 00000000..d513ccc4 --- /dev/null +++ b/crates/vespera_macro/src/multipart_impl/attrs.rs @@ -0,0 +1,370 @@ +use proc_macro2::TokenStream; +use quote::quote; + +use super::fields::DefaultKind; +use super::types::{parse_byte_unit, strip_raw_prefix}; +use crate::parser::{extract_default, extract_field_rename, rename_field}; + +/// Resolve the multipart field name using serde + form_data attributes. +/// +/// Priority: +/// 1. `#[form_data(field_name = "...")]` +/// 2. `#[serde(rename = "...")]` +/// 3. struct-level `rename_all` applied to Rust field name +/// 4. Rust field name as-is +pub(super) fn resolve_field_name( + ident: &syn::Ident, + attrs: &[syn::Attribute], + rename_all: Option<&str>, +) -> String { + if let Some(name) = extract_form_data_field_name(attrs) { + return name; + } + if let Some(name) = extract_field_rename(attrs) { + return name; + } + let rust_name = strip_raw_prefix(&ident.to_string()); + rename_field(&rust_name, rename_all) +} + +/// Extract `field_name` from `#[form_data(field_name = "...")]`. +fn extract_form_data_field_name(attrs: &[syn::Attribute]) -> Option { + for attr in attrs { + if attr.path().is_ident("form_data") { + let mut found = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("field_name") + && let Ok(value) = meta.value() + && let Ok(lit) = value.parse::() + { + found = Some(lit.value()); + } + Ok(()) + }); + if found.is_some() { + return found; + } + } + } + None +} + +/// Extract `strict` flag from `#[try_from_multipart(strict)]`. +pub(super) fn extract_strict(attrs: &[syn::Attribute]) -> bool { + for attr in attrs { + if attr.path().is_ident("try_from_multipart") { + let mut strict = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("strict") { + strict = true; + } + Ok(()) + }); + if strict { + return true; + } + } + } + false +} + +/// Extract `limit` from `#[form_data(limit = "10MiB")]` and emit as `Option` tokens. +pub(super) fn extract_limit_tokens(attrs: &[syn::Attribute]) -> TokenStream { + for attr in attrs { + if attr.path().is_ident("form_data") { + let mut limit_str = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("limit") + && let Ok(value) = meta.value() + && let Ok(lit) = value.parse::() + { + limit_str = Some(lit.value()); + } + Ok(()) + }); + if let Some(s) = limit_str { + if s == "unlimited" { + return quote! { std::option::Option::None }; + } + if let Some(bytes) = parse_byte_unit(&s) { + return quote! { std::option::Option::Some(#bytes) }; + } + } + } + } + quote! { std::option::Option::None } +} + +/// Resolve the default behavior for a field. +/// +/// Priority: +/// 1. `#[form_data(default)]` — explicit form_data override (bare default) +/// 2. `#[serde(default)]` — bare default via `Default::default()` +/// 3. `#[serde(default = "fn_path")]` — custom default function +/// 4. Struct-level `#[serde(default)]` — all fields get `Default::default()` +/// 5. No default — field is required +pub(super) fn resolve_default_kind(attrs: &[syn::Attribute], struct_default: bool) -> DefaultKind { + if extract_form_data_default(attrs) { + return DefaultKind::Trait; + } + if let Some(serde_default) = extract_default(attrs) { + return serde_default.map_or(DefaultKind::Trait, DefaultKind::Function); + } + if struct_default { + return DefaultKind::Trait; + } + DefaultKind::None +} + +/// Extract `default` flag from `#[form_data(default)]`. +fn extract_form_data_default(attrs: &[syn::Attribute]) -> bool { + for attr in attrs { + if attr.path().is_ident("form_data") { + let mut has_default = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("default") { + has_default = true; + } + Ok(()) + }); + if has_default { + return true; + } + } + } + false +} + +/// Check if the struct has `#[serde(default)]` at the struct level. +pub(super) fn extract_struct_default(attrs: &[syn::Attribute]) -> bool { + extract_default(attrs).is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + use syn::Fields; + + fn parse_field(code: &str) -> syn::Field { + let input: syn::DeriveInput = syn::parse_str(&format!("struct T {{ {code} }}")).unwrap(); + match &input.data { + syn::Data::Struct(s) => match &s.fields { + Fields::Named(n) => n.named.first().unwrap().clone(), + _ => unreachable!(), + }, + _ => unreachable!(), + } + } + + fn parse_attrs(code: &str) -> Vec { + parse_field(code).attrs + } + + fn parse_struct_attrs(code: &str) -> Vec { + let input: syn::DeriveInput = syn::parse_str(code).unwrap(); + input.attrs + } + + #[test] + fn test_extract_form_data_field_name_present() { + let attrs = parse_attrs(r#"#[form_data(field_name = "custom")] pub x: String"#); + assert_eq!( + extract_form_data_field_name(&attrs), + Some("custom".to_string()) + ); + } + + #[test] + fn test_extract_form_data_field_name_absent() { + assert_eq!( + extract_form_data_field_name(&parse_attrs("pub x: String")), + None + ); + } + + #[test] + fn test_extract_form_data_field_name_other_form_data_attr() { + let attrs = parse_attrs(r#"#[form_data(limit = "100")] pub x: String"#); + assert_eq!(extract_form_data_field_name(&attrs), None); + } + + #[test] + fn test_extract_strict_present() { + let attrs = parse_struct_attrs("#[try_from_multipart(strict)] struct T { }"); + assert!(extract_strict(&attrs)); + } + + #[test] + fn test_extract_strict_absent() { + let attrs = parse_struct_attrs("struct T { }"); + assert!(!extract_strict(&attrs)); + } + + #[test] + fn test_extract_strict_other_attr() { + let attrs = + parse_struct_attrs("#[try_from_multipart(rename_all = \"camelCase\")] struct T { }"); + assert!(!extract_strict(&attrs)); + } + + #[test] + fn test_extract_form_data_default_present() { + assert!(extract_form_data_default(&parse_attrs( + "#[form_data(default)] pub x: i32" + ))); + } + + #[test] + fn test_extract_form_data_default_absent() { + assert!(!extract_form_data_default(&parse_attrs("pub x: i32"))); + } + + #[test] + fn test_extract_form_data_default_other_form_data() { + let attrs = parse_attrs(r#"#[form_data(limit = "100")] pub x: i32"#); + assert!(!extract_form_data_default(&attrs)); + } + + #[test] + fn test_extract_struct_default_present() { + let attrs = parse_struct_attrs("#[serde(default)] struct T { }"); + assert!(extract_struct_default(&attrs)); + } + + #[test] + fn test_extract_struct_default_absent() { + let attrs = parse_struct_attrs("struct T { }"); + assert!(!extract_struct_default(&attrs)); + } + + #[test] + fn test_resolve_default_kind_none() { + let attrs = parse_attrs("pub x: i32"); + assert!(matches!( + resolve_default_kind(&attrs, false), + DefaultKind::None + )); + } + + #[test] + fn test_resolve_default_kind_serde_default() { + let attrs = parse_attrs("#[serde(default)] pub x: i32"); + assert!(matches!( + resolve_default_kind(&attrs, false), + DefaultKind::Trait + )); + } + + #[test] + fn test_resolve_default_kind_serde_default_fn() { + let attrs = parse_attrs(r#"#[serde(default = "my_fn")] pub x: i32"#); + assert!( + matches!(resolve_default_kind(&attrs, false), DefaultKind::Function(ref f) if f == "my_fn") + ); + } + + #[test] + fn test_resolve_default_kind_form_data_default() { + let attrs = parse_attrs("#[form_data(default)] pub x: i32"); + assert!(matches!( + resolve_default_kind(&attrs, false), + DefaultKind::Trait + )); + } + + #[test] + fn test_resolve_default_kind_struct_level() { + let attrs = parse_attrs("pub x: i32"); + assert!(matches!( + resolve_default_kind(&attrs, true), + DefaultKind::Trait + )); + } + + #[test] + fn test_resolve_default_kind_form_data_overrides_struct_default() { + let attrs = parse_attrs("#[form_data(default)] pub x: i32"); + assert!(matches!( + resolve_default_kind(&attrs, true), + DefaultKind::Trait + )); + } + + #[test] + fn test_resolve_field_name_plain() { + let field = parse_field("pub my_field: String"); + let name = resolve_field_name(field.ident.as_ref().unwrap(), &field.attrs, None); + assert_eq!(name, "my_field"); + } + + #[test] + fn test_resolve_field_name_rename_all() { + let field = parse_field("pub my_field: String"); + let name = resolve_field_name( + field.ident.as_ref().unwrap(), + &field.attrs, + Some("camelCase"), + ); + assert_eq!(name, "myField"); + } + + #[test] + fn test_resolve_field_name_serde_rename() { + let field = parse_field(r#"#[serde(rename = "custom")] pub my_field: String"#); + let name = resolve_field_name( + field.ident.as_ref().unwrap(), + &field.attrs, + Some("camelCase"), + ); + assert_eq!(name, "custom"); + } + + #[test] + fn test_resolve_field_name_form_data_field_name() { + let field = parse_field( + r#"#[form_data(field_name = "override")] #[serde(rename = "serde_name")] pub my_field: String"#, + ); + let name = resolve_field_name( + field.ident.as_ref().unwrap(), + &field.attrs, + Some("camelCase"), + ); + assert_eq!(name, "override"); + } + + #[test] + fn test_extract_limit_tokens_none() { + assert_eq!( + extract_limit_tokens(&parse_attrs("pub x: String")).to_string(), + "std :: option :: Option :: None" + ); + } + + #[test] + fn test_extract_limit_tokens_with_value() { + let attrs = parse_attrs(r#"#[form_data(limit = "100")] pub x: String"#); + assert_eq!( + extract_limit_tokens(&attrs).to_string(), + "std :: option :: Option :: Some (100usize)" + ); + } + + #[test] + fn test_extract_limit_tokens_unlimited() { + let attrs = parse_attrs(r#"#[form_data(limit = "unlimited")] pub x: String"#); + assert_eq!( + extract_limit_tokens(&attrs).to_string(), + "std :: option :: Option :: None" + ); + } + + #[test] + fn test_extract_limit_tokens_mib() { + let attrs = parse_attrs(r#"#[form_data(limit = "10MiB")] pub x: String"#); + let expected = 10 * 1024 * 1024; + assert_eq!( + extract_limit_tokens(&attrs).to_string(), + format!("std :: option :: Option :: Some ({expected}usize)") + ); + } +} diff --git a/crates/vespera_macro/src/multipart_impl/fields.rs b/crates/vespera_macro/src/multipart_impl/fields.rs new file mode 100644 index 00000000..ecd27735 --- /dev/null +++ b/crates/vespera_macro/src/multipart_impl/fields.rs @@ -0,0 +1,297 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::Type; + +use super::attrs::{extract_limit_tokens, resolve_default_kind, resolve_field_name}; +use super::types::{extract_inner_generic, is_option_type, is_vec_type}; + +/// Collected codegen fragments for each struct field. +pub(super) struct FieldCodegen<'a> { + pub(super) declarations: Vec, + pub(super) assignments: Vec, + pub(super) post_loop: Vec, + pub(super) idents: Vec<&'a syn::Ident>, +} + +/// How a missing field should be handled. +pub(super) enum DefaultKind { + /// No default — field is required; emit `MissingField` error. + None, + /// Use `Default::default()` — from `#[serde(default)]` or `#[form_data(default)]`. + Trait, + /// Call a custom function — from `#[serde(default = "path::to::fn")]`. + Function(String), +} + +/// Process all named fields into codegen fragments. +pub(super) fn process_fields<'a>( + fields: impl Iterator, + rename_all: Option<&str>, + strict: bool, + struct_default: bool, +) -> FieldCodegen<'a> { + let mut cg = FieldCodegen { + declarations: Vec::new(), + assignments: Vec::new(), + post_loop: Vec::new(), + idents: Vec::new(), + }; + + for field in fields { + let ident = field.ident.as_ref().unwrap(); + let ty = &field.ty; + let is_vec = is_vec_type(ty); + let is_option = is_option_type(ty); + let field_name = resolve_field_name(ident, &field.attrs, rename_all); + let limit_tokens = extract_limit_tokens(&field.attrs); + let default_kind = resolve_default_kind(&field.attrs, struct_default); + + let parse_ty = if is_option || is_vec { + extract_inner_generic(ty).unwrap_or_else(|| ty.clone()) + } else { + ty.clone() + }; + + push_declaration(&mut cg, ident, ty, is_vec, is_option); + push_assignment( + &mut cg, + ident, + &parse_ty, + &field_name, + &limit_tokens, + is_vec, + strict, + ); + push_post_loop( + &mut cg, + ident, + ty, + &field_name, + &default_kind, + is_option, + is_vec, + ); + cg.idents.push(ident); + } + + cg +} + +fn push_declaration<'a>( + cg: &mut FieldCodegen<'a>, + ident: &'a syn::Ident, + ty: &Type, + is_vec: bool, + is_option: bool, +) { + if is_vec { + cg.declarations + .push(quote! { let mut #ident: #ty = std::vec::Vec::new(); }); + } else if is_option { + cg.declarations + .push(quote! { let mut #ident: #ty = std::option::Option::None; }); + } else { + cg.declarations + .push(quote! { let mut #ident: std::option::Option<#ty> = std::option::Option::None; }); + } +} + +fn push_assignment<'a>( + cg: &mut FieldCodegen<'a>, + ident: &'a syn::Ident, + parse_ty: &Type, + field_name: &str, + limit_tokens: &TokenStream, + is_vec: bool, + strict: bool, +) { + // Explicit turbofish types are required because RPITIT opaque return types + // prevent the compiler from inferring `TryFromFieldWithState::Self` through `.await`. + let try_from_call = quote! { <#parse_ty as vespera::multipart::TryFromFieldWithState<__VesperaS__>>::try_from_field_with_state }; + let parse_value = quote! { #try_from_call(__field__, #limit_tokens, __state__).await? }; + + let assignment = if is_vec { + quote! { #ident.push(#parse_value); } + } else if strict { + let set_value = quote! { #ident = std::option::Option::Some(#parse_value) }; + let dup_err = quote! { return std::result::Result::Err(vespera::multipart::TypedMultipartError::DuplicateField { field_name: std::string::String::from(#field_name) }) }; + quote! { if #ident.is_none() { #set_value ; } else { #dup_err ; } } + } else { + quote! { #ident = std::option::Option::Some(#parse_value); } + }; + + cg.assignments + .push(quote! { if __field_name__ == #field_name { #assignment } }); +} + +fn push_post_loop<'a>( + cg: &mut FieldCodegen<'a>, + ident: &'a syn::Ident, + ty: &Type, + field_name: &str, + default_kind: &DefaultKind, + is_option: bool, + is_vec: bool, +) { + if is_option || is_vec { + return; + } + + match default_kind { + DefaultKind::Trait => { + cg.post_loop + .push(quote! { let #ident: #ty = #ident.unwrap_or_default(); }); + } + DefaultKind::Function(fn_path) => { + let path: syn::ExprPath = + syn::parse_str(fn_path).expect("invalid default function path"); + cg.post_loop + .push(quote! { let #ident: #ty = #ident.unwrap_or_else(#path); }); + } + DefaultKind::None => { + cg.post_loop.push(quote! { + let #ident = #ident.ok_or( + vespera::multipart::TypedMultipartError::MissingField { + field_name: std::string::String::from(#field_name) + } + )?; + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use syn::Fields; + + fn parse_fields_from(code: &str) -> syn::DeriveInput { + syn::parse_str(code).unwrap() + } + + fn get_named_fields( + input: &syn::DeriveInput, + ) -> &syn::punctuated::Punctuated { + match &input.data { + syn::Data::Struct(s) => match &s.fields { + Fields::Named(n) => &n.named, + _ => panic!("expected named fields"), + }, + _ => panic!("expected struct"), + } + } + + #[test] + fn test_process_fields_required_field_generates_parse_value() { + let input = parse_fields_from("struct T { pub name: String }"); + let fields = get_named_fields(&input); + let cg = process_fields(fields.iter(), None, false, false); + + let assignment_code = cg + .assignments + .iter() + .map(ToString::to_string) + .collect::>() + .join(" "); + assert!(assignment_code.contains("TryFromFieldWithState")); + assert!(assignment_code.contains("try_from_field_with_state")); + assert!(assignment_code.contains("\"name\"")); + + let post_code = cg + .post_loop + .iter() + .map(ToString::to_string) + .collect::>() + .join(" "); + assert!(post_code.contains("MissingField")); + } + + #[test] + fn test_process_fields_strict_required_field_generates_duplicate_check() { + let input = parse_fields_from("struct T { pub name: String, pub age: i32 }"); + let fields = get_named_fields(&input); + let cg = process_fields(fields.iter(), None, true, false); + + let assignment_code = cg + .assignments + .iter() + .map(ToString::to_string) + .collect::>() + .join(" "); + assert!(assignment_code.contains("is_none")); + assert!(assignment_code.contains("DuplicateField")); + assert!(assignment_code.contains("\"name\"")); + assert!(assignment_code.contains("\"age\"")); + assert!(assignment_code.contains("TryFromFieldWithState")); + } + + #[test] + fn test_process_fields_vec_field_generates_push() { + let input = parse_fields_from("struct T { pub tags: Vec }"); + let fields = get_named_fields(&input); + let cg = process_fields(fields.iter(), None, false, false); + + let decl_code = cg + .declarations + .iter() + .map(ToString::to_string) + .collect::>() + .join(" "); + assert!(decl_code.contains("Vec :: new")); + + let assignment_code = cg + .assignments + .iter() + .map(ToString::to_string) + .collect::>() + .join(" "); + assert!(assignment_code.contains("push")); + assert!(cg.post_loop.is_empty()); + } + + #[test] + fn test_process_fields_option_field_no_missing_check() { + let input = parse_fields_from("struct T { pub bio: Option }"); + let fields = get_named_fields(&input); + let cg = process_fields(fields.iter(), None, false, false); + + let decl_code = cg + .declarations + .iter() + .map(ToString::to_string) + .collect::>() + .join(" "); + assert!(decl_code.contains("Option :: None")); + assert!(cg.post_loop.is_empty()); + } + + #[test] + fn test_process_fields_strict_vec_field_uses_push_not_duplicate() { + let input = parse_fields_from("struct T { pub tags: Vec }"); + let fields = get_named_fields(&input); + let cg = process_fields(fields.iter(), None, true, false); + + let assignment_code = cg + .assignments + .iter() + .map(ToString::to_string) + .collect::>() + .join(" "); + assert!(assignment_code.contains("push")); + assert!(!assignment_code.contains("DuplicateField")); + } + + #[test] + fn test_process_fields_mixed_types() { + let input = parse_fields_from( + "struct T { pub name: String, pub tags: Vec, pub bio: Option }", + ); + let fields = get_named_fields(&input); + let cg = process_fields(fields.iter(), None, false, false); + + assert_eq!(cg.idents.len(), 3); + assert_eq!(cg.declarations.len(), 3); + assert_eq!(cg.assignments.len(), 3); + assert_eq!(cg.post_loop.len(), 1); + } +} diff --git a/crates/vespera_macro/src/multipart_impl/mod.rs b/crates/vespera_macro/src/multipart_impl/mod.rs new file mode 100644 index 00000000..e16e120c --- /dev/null +++ b/crates/vespera_macro/src/multipart_impl/mod.rs @@ -0,0 +1,236 @@ +//! Vespera's `Multipart` derive macro implementation. +//! +//! This is a re-implementation of `axum_typed_multipart`'s derive macro that +//! natively supports `#[serde(rename_all)]` and `#[serde(rename)]` attributes +//! for field name resolution in multipart form data. +//! +//! ## Why? +//! +//! `axum_typed_multipart`'s derive macro only reads `#[try_from_multipart(rename_all)]` +//! and ignores `#[serde(rename_all)]`. This causes a mismatch: the OpenAPI spec +//! (generated by `Schema` derive) shows camelCase field names, but the runtime +//! multipart parser expects snake_case Rust field names. +//! +//! ## Field Name Resolution Priority +//! +//! 1. `#[form_data(field_name = "...")]` — explicit override (highest priority) +//! 2. `#[serde(rename = "...")]` — serde field rename +//! 3. `#[serde(rename_all = "...")]` or `#[try_from_multipart(rename_all = "...")]` applied to Rust name +//! 4. Rust field name as-is (lowest priority) + +mod attrs; +mod fields; +mod types; + +use proc_macro2::TokenStream; +use quote::quote; +use syn::{DeriveInput, Fields}; + +use self::attrs::{extract_strict, extract_struct_default}; +use self::fields::{FieldCodegen, process_fields}; + +/// Process the `#[derive(TryFromMultipart)]` macro input. +pub fn process_derive(input: &DeriveInput) -> TokenStream { + let struct_name = &input.ident; + let rename_all = crate::parser::extract_rename_all(&input.attrs); + let strict = extract_strict(&input.attrs); + let struct_default = extract_struct_default(&input.attrs); + + let fields = match &input.data { + syn::Data::Struct(data) => match &data.fields { + Fields::Named(named) => &named.named, + _ => { + return syn::Error::new_spanned( + &input.ident, + "Multipart only supports structs with named fields", + ) + .to_compile_error(); + } + }, + _ => { + return syn::Error::new_spanned( + &input.ident, + "Multipart can only be derived for structs", + ) + .to_compile_error(); + } + }; + + let mut cg = process_fields(fields.iter(), rename_all.as_deref(), strict, struct_default); + + if strict { + // Cold path: allocate the owned name only when the request is + // about to be rejected. + cg.assignments.push(quote! { + { + return std::result::Result::Err( + vespera::multipart::TypedMultipartError::UnknownField { + field_name: std::string::String::from(__field_name__) + } + ); + } + }); + } + + let missing_name_fallback = if strict { + quote! { + return std::result::Result::Err( + vespera::multipart::TypedMultipartError::NamelessField + ) + } + } else { + quote! { continue } + }; + + let FieldCodegen { + declarations, + assignments, + post_loop, + idents, + } = &cg; + + quote! { + impl<__VesperaS__: Send + Sync> vespera::multipart::TryFromMultipartWithState<__VesperaS__> for #struct_name { + async fn try_from_multipart_with_state( + __multipart__: &mut vespera::axum::extract::Multipart, + __state__: &__VesperaS__, + ) -> std::result::Result { + #(#declarations)* + + while let std::option::Option::Some(__field__) = __multipart__ + .next_field().await + .map_err(vespera::multipart::TypedMultipartError::from)? { + // Borrowed `&str` — NLL ends the borrow on each match + // arm before `__field__` is consumed by the parser, so + // no per-field `String` allocation is needed. + let __field_name__ = match __field__.name() { + | std::option::Option::Some("") + | std::option::Option::None => #missing_name_fallback, + | std::option::Option::Some(__name__) => __name__, + }; + + #(#assignments) else * + } + + #(#post_loop)* + + std::result::Result::Ok(Self { #(#idents),* }) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_process_derive_basic_struct() { + let input: syn::DeriveInput = + syn::parse_str("struct MyForm { pub name: String, pub age: i32 }").unwrap(); + let code = process_derive(&input).to_string(); + assert!(code.contains("TryFromMultipartWithState")); + assert!(code.contains("MyForm")); + assert!(code.contains("\"name\"")); + assert!(code.contains("\"age\"")); + } + + #[test] + fn test_process_derive_with_option_field() { + let input: syn::DeriveInput = + syn::parse_str("struct MyForm { pub name: String, pub bio: Option }").unwrap(); + let code = process_derive(&input).to_string(); + assert!(code.contains("TryFromMultipartWithState")); + assert!(code.contains("Option :: None")); + } + + #[test] + fn test_process_derive_with_vec_field() { + let input: syn::DeriveInput = + syn::parse_str("struct MyForm { pub name: String, pub tags: Vec }").unwrap(); + let code = process_derive(&input).to_string(); + assert!(code.contains("Vec :: new")); + assert!(code.contains("push")); + } + + #[test] + fn test_process_derive_strict_mode() { + let input: syn::DeriveInput = + syn::parse_str("#[try_from_multipart(strict)] struct MyForm { pub name: String }") + .unwrap(); + let code = process_derive(&input).to_string(); + assert!(code.contains("DuplicateField")); + assert!(code.contains("UnknownField")); + assert!(code.contains("NamelessField")); + } + + #[test] + fn test_process_derive_with_rename_all() { + let input: syn::DeriveInput = syn::parse_str( + r#"#[serde(rename_all = "camelCase")] struct MyForm { pub user_name: String }"#, + ) + .unwrap(); + assert!(process_derive(&input).to_string().contains("\"userName\"")); + } + + #[test] + fn test_process_derive_with_serde_default() { + let input: syn::DeriveInput = + syn::parse_str("#[serde(default)] struct MyForm { pub count: i32 }").unwrap(); + assert!( + process_derive(&input) + .to_string() + .contains("unwrap_or_default") + ); + } + + #[test] + fn test_process_derive_with_field_default_fn() { + let input: syn::DeriveInput = + syn::parse_str(r#"struct MyForm { #[serde(default = "my_default")] pub val: String }"#) + .unwrap(); + let code = process_derive(&input).to_string(); + assert!(code.contains("unwrap_or_else")); + assert!(code.contains("my_default")); + } + + #[test] + fn test_process_derive_non_struct_errors() { + let input: syn::DeriveInput = syn::parse_str("enum Foo { A, B }").unwrap(); + assert!(process_derive(&input).to_string().contains("compile_error")); + } + + #[test] + fn test_process_derive_tuple_struct_errors() { + let input: syn::DeriveInput = syn::parse_str("struct Foo(String, i32);").unwrap(); + assert!(process_derive(&input).to_string().contains("compile_error")); + } + + #[test] + fn test_process_derive_form_data_field_name() { + let input: syn::DeriveInput = syn::parse_str( + r#"struct MyForm { #[form_data(field_name = "custom")] pub data: String }"#, + ) + .unwrap(); + assert!(process_derive(&input).to_string().contains("\"custom\"")); + } + + #[test] + fn test_process_derive_form_data_default() { + let input: syn::DeriveInput = + syn::parse_str("struct MyForm { #[form_data(default)] pub count: i32 }").unwrap(); + assert!( + process_derive(&input) + .to_string() + .contains("unwrap_or_default") + ); + } + + #[test] + fn test_process_derive_non_strict_no_duplicate_check() { + let input: syn::DeriveInput = syn::parse_str("struct MyForm { pub name: String }").unwrap(); + let code = process_derive(&input).to_string(); + assert!(!code.contains("DuplicateField")); + assert!(!code.contains("UnknownField")); + } +} diff --git a/crates/vespera_macro/src/multipart_impl/types.rs b/crates/vespera_macro/src/multipart_impl/types.rs new file mode 100644 index 00000000..8e70c951 --- /dev/null +++ b/crates/vespera_macro/src/multipart_impl/types.rs @@ -0,0 +1,177 @@ +use syn::Type; + +/// Extract the first generic type argument from a type like `Option` or `Vec`. +pub(super) fn extract_inner_generic(ty: &Type) -> Option { + let Type::Path(type_path) = ty else { + return None; + }; + let segment = type_path.path.segments.last()?; + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments + && let Some(syn::GenericArgument::Type(inner)) = args.args.first() + { + return Some(inner.clone()); + } + None +} + +/// Check if a type matches `Option`. +pub(super) fn is_option_type(ty: &Type) -> bool { + matches_type_name( + ty, + &["Option", "std::option::Option", "core::option::Option"], + ) +} + +/// Check if a type matches `Vec`. +pub(super) fn is_vec_type(ty: &Type) -> bool { + matches_type_name(ty, &["Vec", "std::vec::Vec"]) +} + +/// Check if a type's path matches any of the given names. +fn matches_type_name(ty: &Type, names: &[&str]) -> bool { + let path = match ty { + Type::Path(type_path) if type_path.qself.is_none() => &type_path.path, + _ => return false, + }; + let sig = path + .segments + .iter() + .map(|s| s.ident.to_string()) + .collect::>() + .join("::"); + names.contains(&sig.as_str()) +} + +/// Strip leading `r#` from raw identifiers. +pub(super) fn strip_raw_prefix(s: &str) -> String { + s.strip_prefix("r#").unwrap_or(s).to_string() +} + +/// Parse a human-readable byte unit string into bytes. +/// +/// Supports: `"10MiB"`, `"1GB"`, `"500KB"`, `"1024"`, `"unlimited"`. +pub(super) fn parse_byte_unit(s: &str) -> Option { + let s = s.trim(); + + // Binary and decimal suffixes, longest first to avoid prefix collisions + let suffixes: &[(&str, usize)] = &[ + ("GiB", 1024 * 1024 * 1024), + ("MiB", 1024 * 1024), + ("KiB", 1024), + ("GB", 1_000_000_000), + ("MB", 1_000_000), + ("KB", 1_000), + ("B", 1), + ]; + + for (suffix, multiplier) in suffixes { + if let Some(num_str) = s.strip_suffix(suffix) { + return num_str.trim().parse::().ok().map(|n| n * multiplier); + } + } + + // Plain number (bytes) + s.parse::().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use quote::quote; + + #[test] + fn test_parse_byte_unit() { + assert_eq!(parse_byte_unit("10MiB"), Some(10 * 1024 * 1024)); + assert_eq!(parse_byte_unit("50MiB"), Some(50 * 1024 * 1024)); + assert_eq!(parse_byte_unit("1GB"), Some(1_000_000_000)); + assert_eq!(parse_byte_unit("500KB"), Some(500_000)); + assert_eq!(parse_byte_unit("1024"), Some(1024)); + assert_eq!(parse_byte_unit("0"), Some(0)); + assert_eq!(parse_byte_unit("invalid"), None); + } + + #[test] + fn test_parse_byte_unit_all_suffixes() { + assert_eq!(parse_byte_unit("1GiB"), Some(1024 * 1024 * 1024)); + assert_eq!(parse_byte_unit("2KiB"), Some(2 * 1024)); + assert_eq!(parse_byte_unit("3MB"), Some(3_000_000)); + assert_eq!(parse_byte_unit("4B"), Some(4)); + assert_eq!(parse_byte_unit(" 5MiB "), Some(5 * 1024 * 1024)); + } + + #[test] + fn test_strip_raw_prefix() { + assert_eq!(strip_raw_prefix("r#type"), "type"); + assert_eq!(strip_raw_prefix("normal"), "normal"); + } + + #[test] + fn test_extract_inner_generic_option() { + let ty: syn::Type = syn::parse_str("Option").unwrap(); + let inner = extract_inner_generic(&ty).unwrap(); + assert_eq!(quote!(#inner).to_string(), "String"); + } + + #[test] + fn test_extract_inner_generic_vec() { + let ty: syn::Type = syn::parse_str("Vec").unwrap(); + let inner = extract_inner_generic(&ty).unwrap(); + assert_eq!(quote!(#inner).to_string(), "i32"); + } + + #[test] + fn test_extract_inner_generic_no_generics() { + let ty: syn::Type = syn::parse_str("String").unwrap(); + assert!(extract_inner_generic(&ty).is_none()); + } + + #[test] + fn test_extract_inner_generic_non_path() { + let ty: syn::Type = syn::parse_str("(i32, String)").unwrap(); + assert!(extract_inner_generic(&ty).is_none()); + } + + #[test] + fn test_is_option_type() { + let ty: syn::Type = syn::parse_str("Option").unwrap(); + assert!(is_option_type(&ty)); + let ty: syn::Type = syn::parse_str("std::option::Option").unwrap(); + assert!(is_option_type(&ty)); + let ty: syn::Type = syn::parse_str("Vec").unwrap(); + assert!(!is_option_type(&ty)); + let ty: syn::Type = syn::parse_str("String").unwrap(); + assert!(!is_option_type(&ty)); + } + + #[test] + fn test_is_vec_type() { + let ty: syn::Type = syn::parse_str("Vec").unwrap(); + assert!(is_vec_type(&ty)); + let ty: syn::Type = syn::parse_str("std::vec::Vec").unwrap(); + assert!(is_vec_type(&ty)); + let ty: syn::Type = syn::parse_str("Option").unwrap(); + assert!(!is_vec_type(&ty)); + let ty: syn::Type = syn::parse_str("String").unwrap(); + assert!(!is_vec_type(&ty)); + } + + #[test] + fn test_matches_type_name_simple() { + let ty: syn::Type = syn::parse_str("Option").unwrap(); + assert!(matches_type_name(&ty, &["Option"])); + assert!(!matches_type_name(&ty, &["Vec"])); + } + + #[test] + fn test_matches_type_name_qualified() { + let ty: syn::Type = syn::parse_str("std::option::Option").unwrap(); + assert!(matches_type_name(&ty, &["std::option::Option"])); + assert!(!matches_type_name(&ty, &["Option"])); + } + + #[test] + fn test_matches_type_name_non_path() { + let ty: syn::Type = syn::parse_str("(i32, String)").unwrap(); + assert!(!matches_type_name(&ty, &["Option", "Vec"])); + } +} diff --git a/crates/vespera_macro/src/openapi_generator.rs b/crates/vespera_macro/src/openapi_generator.rs index 5311faa7..cc2d8385 100644 --- a/crates/vespera_macro/src/openapi_generator.rs +++ b/crates/vespera_macro/src/openapi_generator.rs @@ -1,23 +1,23 @@ //! `OpenAPI` document generator -use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; -use std::path::Path; +use std::collections::HashMap; use vespera_core::{ openapi::{Info, OpenApi, OpenApiVersion, Server, Tag}, - route::{HttpMethod, PathItem}, schema::Components, }; -use crate::{ - metadata::CollectedMetadata, - parser::{ - build_operation_from_function, extract_default, extract_field_rename, extract_rename_all, - parse_enum_to_schema, parse_struct_to_schema, rename_field, strip_raw_prefix_owned, - }, - route_impl::StoredRouteInfo, - schema_macro::type_utils::get_type_default as utils_get_type_default, +use crate::{metadata::CollectedMetadata, route_impl::StoredRouteInfo}; + +mod component_schemas; +mod defaults; +mod paths; + +use component_schemas::{ + build_file_cache, build_schema_lookups, build_struct_file_index, parse_component_schemas, }; +pub use defaults::{extract_default_value_from_function, find_function_in_file}; +use paths::build_path_items; /// Generate `OpenAPI` document from collected metadata. /// @@ -31,18 +31,30 @@ pub fn generate_openapi_doc_with_metadata( file_cache: Option>, route_storage: &[StoredRouteInfo], ) -> OpenApi { + let profiling = std::env::var("VESPERA_PROFILE").is_ok(); + let mut stage_start = std::time::Instant::now(); + let mut stage = |name: &str| { + if profiling { + eprintln!( + "[vespera-profile] openapi {name}: {:?}", + stage_start.elapsed() + ); + stage_start = std::time::Instant::now(); + } + }; + let (known_schema_names, struct_definitions) = build_schema_lookups(metadata); let file_cache = file_cache.unwrap_or_else(|| build_file_cache(metadata)); let struct_file_index = build_struct_file_index(&file_cache); - let parsed_definitions = build_parsed_definitions(metadata); + stage("lookups + file index"); let schemas = parse_component_schemas( metadata, &known_schema_names, &struct_definitions, - &parsed_definitions, &file_cache, &struct_file_index, ); + stage("component schemas"); let (paths, all_tags) = build_path_items( metadata, &known_schema_names, @@ -50,6 +62,7 @@ pub fn generate_openapi_doc_with_metadata( &file_cache, route_storage, ); + stage("path items"); OpenApi { openapi: OpenApiVersion::V3_1_0, @@ -98,477 +111,15 @@ pub fn generate_openapi_doc_with_metadata( } } -/// Build schema name and definition lookup maps from metadata. -/// -/// Registers ALL structs (including `include_in_openapi: false`) so that -/// `schema_type!` generated types can reference them. -fn build_schema_lookups( - metadata: &CollectedMetadata, -) -> (HashSet, HashMap) { - let mut known_schema_names = HashSet::with_capacity(metadata.structs.len()); - let mut struct_definitions = HashMap::with_capacity(metadata.structs.len()); - - for struct_meta in &metadata.structs { - struct_definitions.insert(struct_meta.name.clone(), struct_meta.definition.clone()); - known_schema_names.insert(struct_meta.name.clone()); - } - - (known_schema_names, struct_definitions) -} - -/// Build file AST cache — parse each unique route file exactly once. -/// -/// Deduplicates file paths first, then parses each file a single time. -/// This eliminates redundant file I/O when multiple routes share a source file. -fn build_file_cache(metadata: &CollectedMetadata) -> HashMap { - let unique_paths: BTreeSet<&str> = metadata - .routes - .iter() - .map(|r| r.file_path.as_str()) - .collect(); - let mut cache = HashMap::with_capacity(unique_paths.len()); - for path in unique_paths { - if let Some(ast) = crate::schema_macro::file_cache::get_parsed_file(Path::new(path)) { - cache.insert(path.to_string(), ast); - } - } - cache -} - -/// Build struct name → file path index from cached file ASTs. -/// -/// Enables O(1) lookup of which file contains a given struct definition, -/// replacing the previous O(routes × file_read) linear scan. -fn build_struct_file_index(file_cache: &HashMap) -> HashMap { - let mut index = HashMap::with_capacity(file_cache.len() * 4); - for (path, ast) in file_cache { - for item in &ast.items { - if let syn::Item::Struct(s) = item { - index.insert(s.ident.to_string(), path.as_str()); - } - } - } - index -} - -/// Pre-parse all struct/enum definitions into `syn::Item` for reuse. -/// -/// Avoids calling `syn::parse_str` per-struct inside `parse_component_schemas()` -/// and other consumers that need the parsed AST. -fn build_parsed_definitions(metadata: &CollectedMetadata) -> HashMap { - let mut parsed = HashMap::with_capacity(metadata.structs.len()); - for struct_meta in &metadata.structs { - if let Ok(item) = syn::parse_str::(&struct_meta.definition) { - parsed.insert(struct_meta.name.clone(), item); - } - } - parsed -} - -/// Parse struct and enum definitions into `OpenAPI` component schemas. -/// -/// Only includes structs where `include_in_openapi` is true -/// (i.e., from `#[derive(Schema)]`, not from cross-file lookup). -/// Also processes `#[serde(default)]` attributes to extract default values. -/// -/// Uses pre-built `file_cache` and `struct_file_index` for O(1) file lookups -/// instead of scanning all route files per struct. -fn parse_component_schemas( - metadata: &CollectedMetadata, - known_schema_names: &HashSet, - struct_definitions: &HashMap, - parsed_definitions: &HashMap, - file_cache: &HashMap, - struct_file_index: &HashMap, -) -> BTreeMap { - let mut schemas = BTreeMap::new(); - - for struct_meta in metadata.structs.iter().filter(|s| s.include_in_openapi) { - let Some(parsed) = parsed_definitions.get(&struct_meta.name) else { - continue; - }; - let mut schema = match parsed { - syn::Item::Struct(struct_item) => { - parse_struct_to_schema(struct_item, known_schema_names, struct_definitions) - } - syn::Item::Enum(enum_item) => { - parse_enum_to_schema(enum_item, known_schema_names, struct_definitions) - } - _ => continue, - }; - - // Process default values using cached file ASTs (O(1) lookup) - if let syn::Item::Struct(struct_item) = parsed { - let file_ast = struct_file_index - .get(&struct_meta.name) - .and_then(|path| file_cache.get(*path)) - .or_else(|| { - metadata - .routes - .first() - .and_then(|r| file_cache.get(&r.file_path)) - }); - - if let Some(ast) = file_ast { - process_default_functions( - struct_item, - ast, - &mut schema, - &struct_meta.field_defaults, - ); - } - } - - schemas.insert(struct_meta.name.clone(), schema); - } - - schemas -} - -/// Build path items and collect tags from route metadata. -/// -/// Uses `route_storage` (from `#[route]` macro) as the primary source for function -/// signatures. Falls back to pre-built `file_cache` when ROUTE_STORAGE doesn't -/// have an entry (e.g., during tests or for routes added without the attribute). -fn build_path_items( - metadata: &CollectedMetadata, - known_schema_names: &HashSet, - struct_definitions: &HashMap, - file_cache: &HashMap, - route_storage: &[StoredRouteInfo], -) -> (BTreeMap, BTreeSet) { - let mut paths = BTreeMap::new(); - let mut all_tags = BTreeSet::new(); - - // Build the file-AST function index FIRST so the storage-parse step - // below can skip any function whose AST is already reachable through - // `file_cache`. `collector::collect_metadata` has already walked - // these files via `syn::parse_file`, so re-parsing `fn_item_str` - // from ROUTE_STORAGE for the same function is pure duplicated work. - let fn_index: HashMap<&str, HashMap> = file_cache - .iter() - .map(|(path, ast)| { - let fns: HashMap = ast - .items - .iter() - .filter_map(|item| { - if let syn::Item::Fn(fn_item) = item { - Some((fn_item.sig.ident.to_string(), fn_item)) - } else { - None - } - }) - .collect(); - (path.as_str(), fns) - }) - .collect(); - - // Primary source: parse function items from ROUTE_STORAGE only when - // the function is *not* already covered by `fn_index`. Routes whose - // owning file is in `file_cache` short-circuit through `fn_index` in - // the loop below, so the parse is wasted work. The lookup order in - // the loop preserves the original ROUTE_STORAGE-first priority for - // any route that does end up in this cache (e.g. routes registered - // via `#[route]` from files outside the scanned routes folder). - let route_fn_cache: HashMap<&str, syn::ItemFn> = route_storage - .iter() - .filter_map(|s| { - let already_in_ast = s - .file_path - .as_deref() - .and_then(|fp| fn_index.get(fp)) - .is_some_and(|fns| fns.contains_key(&s.fn_name)); - if already_in_ast { - return None; - } - syn::parse_str::(&s.fn_item_str) - .ok() - .map(|item| (s.fn_name.as_str(), item)) - }) - .collect(); - - for route_meta in &metadata.routes { - // Try ROUTE_STORAGE first (avoids file_cache dependency for known routes) - let fn_sig = if let Some(cached_fn) = route_fn_cache.get(route_meta.function_name.as_str()) - { - &cached_fn.sig - } else if let Some(fns) = fn_index.get(route_meta.file_path.as_str()) - && let Some(fn_item) = fns.get(&route_meta.function_name) - { - &fn_item.sig - } else { - continue; - }; - - let Ok(method) = HttpMethod::try_from(route_meta.method.as_str()) else { - eprintln!( - "vespera: skipping route '{}' \u{2014} unknown HTTP method '{}'", - route_meta.path, route_meta.method - ); - continue; - }; - - if let Some(tags) = &route_meta.tags { - for tag in tags { - all_tags.insert(tag.clone()); - } - } - - let mut operation = build_operation_from_function( - fn_sig, - &route_meta.path, - known_schema_names, - struct_definitions, - route_meta.error_status.as_deref(), - route_meta.tags.as_deref(), - ); - operation.description.clone_from(&route_meta.description); - - let path_item = paths - .entry(route_meta.path.clone()) - .or_insert_with(PathItem::default); - - path_item.set_operation(method, operation); - } - - (paths, all_tags) -} - -/// Set the default value on an inline property schema, if not already set. -/// -/// Looks up `field_name` in the properties map. If found as an inline schema -/// and the schema has no existing default, sets `value` as the default. -fn set_property_default( - properties: &mut BTreeMap, - field_name: &str, - value: serde_json::Value, -) { - use vespera_core::schema::SchemaRef; - - if let Some(SchemaRef::Inline(prop_schema)) = properties.get_mut(field_name) - && prop_schema.default.is_none() - { - prop_schema.default = Some(value); - } -} - -/// Process default functions for struct fields -/// This function extracts default values from: -/// 1. `#[schema(default = "value")]` attributes (generated by `schema_type!` from `sea_orm(default_value)`) -/// 2. `#[serde(default = "function_name")]` by finding the function in the file AST -/// 3. `#[serde(default)]` by using type-specific defaults -fn process_default_functions( - struct_item: &syn::ItemStruct, - file_ast: &syn::File, - schema: &mut vespera_core::schema::Schema, - stored_defaults: &BTreeMap, -) { - use syn::Fields; - - // Extract rename_all from struct level - let struct_rename_all = extract_rename_all(&struct_item.attrs); - - // Get properties from schema - let Some(properties) = &mut schema.properties else { - return; - }; - - // Process each field in the struct - if let Fields::Named(fields_named) = &struct_item.fields { - for field in &fields_named.named { - let rust_field_name = field.ident.as_ref().map_or_else( - || "unknown".to_string(), - |i| strip_raw_prefix_owned(i.to_string()), - ); - let field_name = extract_field_rename(&field.attrs) - .unwrap_or_else(|| rename_field(&rust_field_name, struct_rename_all.as_deref())); - - // Priority 0: Pre-extracted defaults from SCHEMA_STORAGE (populated by #[derive(Schema)]) - if let Some(value) = stored_defaults.get(&rust_field_name) { - set_property_default(properties, &field_name, value.clone()); - continue; - } - - // Priority 1: #[schema(default = "value")] from schema_type! macro - if let Some(default_str) = extract_schema_default_attr(&field.attrs) { - let value = parse_default_string_to_json_value(&default_str); - set_property_default(properties, &field_name, value); - continue; - } - - // Priority 2: #[serde(default)] / #[serde(default = "fn")] - let default_info = match extract_default(&field.attrs) { - Some(Some(func_name)) => func_name, // default = "function_name" - Some(None) => { - // Simple default (no function) - we can set type-specific defaults - if let Some(default_value) = utils_get_type_default(&field.ty) { - set_property_default(properties, &field_name, default_value); - } - continue; - } - None => continue, // No default attribute - }; - - // Find the function in the file AST and extract default value - if let Some(func_item) = find_function_in_file(file_ast, &default_info) - && let Some(default_value) = extract_default_value_from_function(func_item) - { - set_property_default(properties, &field_name, default_value); - } - } - } -} - -/// Extract `default` value from `#[schema(default = "...")]` field attribute. -/// -/// This attribute is generated by `schema_type!` when converting `sea_orm(default_value)`. -/// It carries the raw default value string for OpenAPI schema generation. -fn extract_schema_default_attr(attrs: &[syn::Attribute]) -> Option { - attrs - .iter() - .filter(|attr| attr.path().is_ident("schema")) - .find_map(|attr| { - let mut default_value = None; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("default") { - let value = meta.value()?; - let lit: syn::LitStr = value.parse()?; - default_value = Some(lit.value()); - } - Ok(()) - }); - default_value - }) -} - -/// Parse a default value string into the appropriate `serde_json::Value`. -/// -/// Tries to infer the JSON type: integer → number → bool → string (fallback). -fn parse_default_string_to_json_value(value: &str) -> serde_json::Value { - // Try integer first - if let Ok(n) = value.parse::() { - return serde_json::Value::Number(n.into()); - } - // Try float - if let Ok(f) = value.parse::() - && let Some(n) = serde_json::Number::from_f64(f) - { - return serde_json::Value::Number(n); - } - // Try bool - if let Ok(b) = value.parse::() { - return serde_json::Value::Bool(b); - } - // Fallback to string - serde_json::Value::String(value.to_string()) -} - -/// Find a function by name in the file AST -pub fn find_function_in_file<'a>( - file_ast: &'a syn::File, - function_name: &str, -) -> Option<&'a syn::ItemFn> { - file_ast.items.iter().find_map(|item| match item { - syn::Item::Fn(fn_item) if fn_item.sig.ident == function_name => Some(fn_item), - _ => None, - }) -} - -/// Extract default value from function body -/// This tries to extract literal values from common patterns like: -/// - "`value".to_string()` -> "value" -/// - 42 -> 42 -/// - true -> true -/// - vec![] -> [] -pub fn extract_default_value_from_function(func: &syn::ItemFn) -> Option { - // Try to find return statement or expression - for stmt in &func.block.stmts { - if let syn::Stmt::Expr(expr, _) = stmt { - // Direct expression (like "value".to_string()) - if let Some(value) = extract_value_from_expr(expr) { - return Some(value); - } - // Or return statement - if let syn::Expr::Return(ret) = expr - && let Some(expr) = &ret.expr - && let Some(value) = extract_value_from_expr(expr) - { - return Some(value); - } - } - } - - None -} - -/// Extract value from expression -pub fn extract_value_from_expr(expr: &syn::Expr) -> Option { - use syn::{Expr, ExprLit, ExprMacro, Lit}; - - match expr { - // Literal values - Expr::Lit(ExprLit { lit, .. }) => match lit { - Lit::Str(s) => Some(serde_json::Value::String(s.value())), - Lit::Int(i) => i - .base10_parse::() - .ok() - .map(|v| serde_json::Value::Number(v.into())), - Lit::Float(f) => f - .base10_parse::() - .ok() - .and_then(serde_json::Number::from_f64) - .map(serde_json::Value::Number), - Lit::Bool(b) => Some(serde_json::Value::Bool(b.value)), - _ => None, - }, - // Method calls like "value".to_string() - Expr::MethodCall(method_call) => { - if method_call.method == "to_string" { - // Get the receiver (the string literal) - // Try direct match first - if let Expr::Lit(ExprLit { - lit: Lit::Str(s), .. - }) = method_call.receiver.as_ref() - { - return Some(serde_json::Value::String(s.value())); - } - // Try to extract from nested expressions (e.g., if the receiver is wrapped) - if let Some(value) = extract_value_from_expr(method_call.receiver.as_ref()) { - return Some(value); - } - } - None - } - // Macro calls like vec![] - Expr::Macro(ExprMacro { mac, .. }) => { - if mac.path.is_ident("vec") { - // Try to parse vec![] as empty array - return Some(serde_json::Value::Array(vec![])); - } - None - } - _ => None, - } -} - #[cfg(test)] mod tests { - use std::{fs, path::PathBuf}; - use rstest::rstest; - use tempfile::TempDir; use super::*; - use crate::metadata::{CollectedMetadata, RouteMetadata, StructMetadata}; - - fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> PathBuf { - let file_path = dir.path().join(filename); - fs::write(&file_path, content).expect("Failed to write temp file"); - file_path - } + use crate::metadata::CollectedMetadata; #[test] - fn test_generate_openapi_empty_metadata() { + fn empty_metadata_uses_openapi_defaults() { let metadata = CollectedMetadata::new(); let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); @@ -586,11 +137,16 @@ mod tests { } #[rstest] - #[case(None, None, "API", "1.0.0")] - #[case(Some("My API".to_string()), None, "My API", "1.0.0")] - #[case(None, Some("2.0.0".to_string()), "API", "2.0.0")] - #[case(Some("Test API".to_string()), Some("3.0.0".to_string()), "Test API", "3.0.0")] - fn test_generate_openapi_title_version( + #[case::defaults(None, None, "API", "1.0.0")] + #[case::custom_title(Some("My API".to_string()), None, "My API", "1.0.0")] + #[case::custom_version(None, Some("2.0.0".to_string()), "API", "2.0.0")] + #[case::custom_both( + Some("Test API".to_string()), + Some("3.0.0".to_string()), + "Test API", + "3.0.0", + )] + fn title_version_cases( #[case] title: Option, #[case] version: Option, #[case] expected_title: &str, @@ -605,434 +161,7 @@ mod tests { } #[test] - fn test_generate_openapi_with_route() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create a test route file - let route_content = r#" -pub fn get_users() -> String { - "users".to_string() -} -"#; - let route_file = create_temp_file(&temp_dir, "users.rs", route_content); - - let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "test::users".to_string(), - file_path: route_file.to_string_lossy().to_string(), - signature: "fn get_users() -> String".to_string(), - error_status: None, - tags: None, - description: None, - }); - - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - - assert!(doc.paths.contains_key("/users")); - let path_item = doc.paths.get("/users").unwrap(); - assert!(path_item.get.is_some()); - let operation = path_item.get.as_ref().unwrap(); - assert_eq!(operation.operation_id, Some("get_users".to_string())); - } - - #[test] - fn test_generate_openapi_route_storage_dedup_skips_already_in_ast() { - // When a route's `fn_item_str` was already discovered by parsing - // the source file via `file_cache`, the storage-parse step must - // skip re-parsing it — exercises the `already_in_ast → return None` - // branch inside `route_fn_cache` construction. - use crate::route_impl::StoredRouteInfo; - - let route_file_path = "/virtual/users.rs".to_string(); - let route_src = "pub fn get_users() -> String { \"users\".to_string() }"; - let parsed: syn::File = syn::parse_str(route_src).expect("route src parses"); - let mut file_cache: HashMap = HashMap::new(); - file_cache.insert(route_file_path.clone(), parsed); - - let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "test::users".to_string(), - file_path: route_file_path.clone(), - signature: "fn get_users() -> String".to_string(), - error_status: None, - tags: None, - description: None, - }); - - // The route is registered in BOTH file_cache (via AST) and - // ROUTE_STORAGE — the storage-parse step must short-circuit. - let route_storage = vec![StoredRouteInfo { - fn_name: "get_users".to_string(), - method: Some("get".to_string()), - custom_path: None, - error_status: None, - tags: None, - description: None, - file_path: Some(route_file_path), - fn_item_str: route_src.to_string(), - }]; - - let doc = generate_openapi_doc_with_metadata( - None, - None, - None, - &metadata, - Some(file_cache), - &route_storage, - ); - - // The route should still be picked up via the file_cache AST - // path — proves dedup didn't break route discovery. - assert!(doc.paths.contains_key("/users")); - let op = doc - .paths - .get("/users") - .unwrap() - .get - .as_ref() - .expect("GET op"); - assert_eq!(op.operation_id, Some("get_users".to_string())); - } - - #[test] - fn test_generate_openapi_with_struct() { - let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "User".to_string(), - definition: "struct User { id: i32, name: String }".to_string(), - ..Default::default() - }); - - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - - assert!(doc.components.as_ref().unwrap().schemas.is_some()); - let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); - assert!(schemas.contains_key("User")); - } - - #[test] - fn test_generate_openapi_with_enum() { - let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "Status".to_string(), - definition: "enum Status { Active, Inactive, Pending }".to_string(), - ..Default::default() - }); - - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - - assert!(doc.components.as_ref().unwrap().schemas.is_some()); - let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); - assert!(schemas.contains_key("Status")); - } - - #[test] - fn test_generate_openapi_with_enum_with_data() { - // Test enum with data (tuple and struct variants) to ensure full coverage - let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "Message".to_string(), - definition: "enum Message { Text(String), User { id: i32, name: String } }".to_string(), - ..Default::default() - }); - - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - - assert!(doc.components.as_ref().unwrap().schemas.is_some()); - let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); - assert!(schemas.contains_key("Message")); - } - - #[test] - fn test_generate_openapi_with_enum_and_route() { - // Test enum used in route to ensure enum parsing is called in route context - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let route_content = r" -pub fn get_status() -> Status { - Status::Active -} -"; - let route_file = create_temp_file(&temp_dir, "status_route.rs", route_content); - - let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "Status".to_string(), - definition: "enum Status { Active, Inactive }".to_string(), - ..Default::default() - }); - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/status".to_string(), - function_name: "get_status".to_string(), - module_path: "test::status_route".to_string(), - file_path: route_file.to_string_lossy().to_string(), - signature: "fn get_status() -> Status".to_string(), - error_status: None, - tags: None, - description: None, - }); - - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - - // Check enum schema - assert!(doc.components.as_ref().unwrap().schemas.is_some()); - let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); - assert!(schemas.contains_key("Status")); - - // Check route - assert!(doc.paths.contains_key("/status")); - } - - #[test] - fn test_generate_openapi_with_fallback_item() { - // Test fallback case for non-struct, non-enum items - // Use a const item which will be parsed as syn::Item::Const first - // This triggers the fallback case (_ branch) which now gracefully skips - // items that cannot be parsed as structs (defensive error handling) - let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "Config".to_string(), - // This will be parsed as syn::Item::Const, triggering the fallback case - // which now safely skips this item instead of panicking - definition: "const CONFIG: i32 = 42;".to_string(), - include_in_openapi: true, - field_defaults: BTreeMap::new(), - }); - - // This should gracefully handle the invalid item (skip it) instead of panicking - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - // The invalid struct definition should be skipped, resulting in no schemas - assert!(doc.components.is_none() || doc.components.as_ref().unwrap().schemas.is_none()); - } - - #[test] - fn test_generate_openapi_with_route_and_struct() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let route_content = r#" -use crate::user::User; - -pub fn get_user() -> User { - User { id: 1, name: "Alice".to_string() } -} -"#; - let route_file = create_temp_file(&temp_dir, "user_route.rs", route_content); - - let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "User".to_string(), - definition: "struct User { id: i32, name: String }".to_string(), - ..Default::default() - }); - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/user".to_string(), - function_name: "get_user".to_string(), - module_path: "test::user_route".to_string(), - file_path: route_file.to_string_lossy().to_string(), - signature: "fn get_user() -> User".to_string(), - error_status: None, - tags: None, - description: None, - }); - - let doc = generate_openapi_doc_with_metadata( - Some("Test API".to_string()), - Some("1.0.0".to_string()), - None, - &metadata, - None, - &[], - ); - - // Check struct schema - assert!(doc.components.as_ref().unwrap().schemas.is_some()); - let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); - assert!(schemas.contains_key("User")); - - // Check route - assert!(doc.paths.contains_key("/user")); - let path_item = doc.paths.get("/user").unwrap(); - assert!(path_item.get.is_some()); - } - - #[test] - fn test_generate_openapi_multiple_routes() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - let route1_content = r#" -pub fn get_users() -> String { - "users".to_string() -} -"#; - let route1_file = create_temp_file(&temp_dir, "users.rs", route1_content); - - let route2_content = r#" -pub fn create_user() -> String { - "created".to_string() -} -"#; - let route2_file = create_temp_file(&temp_dir, "create_user.rs", route2_content); - - let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "test::users".to_string(), - file_path: route1_file.to_string_lossy().to_string(), - signature: "fn get_users() -> String".to_string(), - error_status: None, - tags: None, - description: None, - }); - metadata.routes.push(RouteMetadata { - method: "POST".to_string(), - path: "/users".to_string(), - function_name: "create_user".to_string(), - module_path: "test::create_user".to_string(), - file_path: route2_file.to_string_lossy().to_string(), - signature: "fn create_user() -> String".to_string(), - error_status: None, - tags: None, - description: None, - }); - - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - - assert_eq!(doc.paths.len(), 1); // Same path, different methods - let path_item = doc.paths.get("/users").unwrap(); - assert!(path_item.get.is_some()); - assert!(path_item.post.is_some()); - } - - #[rstest] - // Test file read failures - #[case::route_file_read_failure( - None, - Some(RouteMetadata { - method: "GET".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "test::users".to_string(), - file_path: "/nonexistent/route.rs".to_string(), - signature: "fn get_users() -> String".to_string(), - error_status: None, - tags: None, - description: None, - }), - false, // struct should not be added - false, // route should not be added - )] - #[case::route_file_parse_failure( - None, - Some(RouteMetadata { - method: "GET".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "test::users".to_string(), - file_path: String::new(), // Will be set to temp file with invalid syntax - signature: "fn get_users() -> String".to_string(), - error_status: None, - tags: None, - description: None, - }), - false, // struct should not be added - false, // route should not be added - )] - fn test_generate_openapi_file_errors( - #[case] struct_meta: Option, - #[case] route_meta: Option, - #[case] expect_struct: bool, - #[case] expect_route: bool, - ) { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let mut metadata = CollectedMetadata::new(); - - // Handle struct metadata - if let Some(struct_m) = struct_meta { - // If file_path is empty, create invalid syntax file - metadata.structs.push(struct_m); - } - - // Handle route metadata - if let Some(mut route_m) = route_meta { - // If file_path is empty, create invalid syntax file - if route_m.file_path.is_empty() { - let invalid_file = - create_temp_file(&temp_dir, "invalid_route.rs", "invalid rust syntax {"); - route_m.file_path = invalid_file.to_string_lossy().to_string(); - } - metadata.routes.push(route_m); - } - - // Should not panic, just skip invalid files - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - - // Check struct - if expect_struct { - assert!(doc.components.as_ref().unwrap().schemas.is_some()); - let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); - assert!(schemas.contains_key("User")); - } else if let Some(schemas) = doc.components.as_ref().unwrap().schemas.as_ref() { - assert!(!schemas.contains_key("User")); - } - - // Check route - if expect_route { - assert!(doc.paths.contains_key("/users")); - } else { - assert!(!doc.paths.contains_key("/users")); - } - - // Ensure TempDir is properly closed - drop(temp_dir); - } - - #[test] - fn test_generate_openapi_with_tags_and_description() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let route_content = r#" -pub fn get_users() -> String { - "users".to_string() -} -"#; - let route_file = create_temp_file(&temp_dir, "users.rs", route_content); - - let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "test::users".to_string(), - file_path: route_file.to_string_lossy().to_string(), - signature: "fn get_users() -> String".to_string(), - error_status: Some(vec![404]), - tags: Some(vec!["users".to_string(), "admin".to_string()]), - description: Some("Get all users".to_string()), - }); - - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - - // Check route has description - let path_item = doc.paths.get("/users").unwrap(); - let operation = path_item.get.as_ref().unwrap(); - assert_eq!(operation.description, Some("Get all users".to_string())); - - // Check tags are collected - assert!(doc.tags.is_some()); - let tags = doc.tags.as_ref().unwrap(); - assert!(tags.iter().any(|t| t.name == "users")); - assert!(tags.iter().any(|t| t.name == "admin")); - } - - #[test] - fn test_generate_openapi_with_servers() { + fn explicit_servers_replace_default_server() { let metadata = CollectedMetadata::new(); let servers = vec![ Server { @@ -1050,876 +179,9 @@ pub fn get_users() -> String { let doc = generate_openapi_doc_with_metadata(None, None, Some(servers), &metadata, None, &[]); - assert!(doc.servers.is_some()); - let doc_servers = doc.servers.unwrap(); + let doc_servers = doc.servers.expect("servers present"); assert_eq!(doc_servers.len(), 2); assert_eq!(doc_servers[0].url, "https://api.example.com"); assert_eq!(doc_servers[1].url, "http://localhost:3000"); } - - #[test] - fn test_extract_value_from_expr_int() { - let expr: syn::Expr = syn::parse_str("42").unwrap(); - let value = extract_value_from_expr(&expr); - assert_eq!(value, Some(serde_json::Value::Number(42.into()))); - } - - #[test] - fn test_extract_value_from_expr_float() { - let expr: syn::Expr = syn::parse_str("12.34").unwrap(); - let value = extract_value_from_expr(&expr); - assert!(value.is_some()); - if let Some(serde_json::Value::Number(n)) = value { - assert!((n.as_f64().unwrap() - 12.34).abs() < 0.001); - } - } - - #[test] - fn test_extract_value_from_expr_bool() { - let expr_true: syn::Expr = syn::parse_str("true").unwrap(); - let expr_false: syn::Expr = syn::parse_str("false").unwrap(); - assert_eq!( - extract_value_from_expr(&expr_true), - Some(serde_json::Value::Bool(true)) - ); - assert_eq!( - extract_value_from_expr(&expr_false), - Some(serde_json::Value::Bool(false)) - ); - } - - #[test] - fn test_extract_value_from_expr_string() { - let expr: syn::Expr = syn::parse_str(r#""hello""#).unwrap(); - let value = extract_value_from_expr(&expr); - assert_eq!(value, Some(serde_json::Value::String("hello".to_string()))); - } - - #[test] - fn test_extract_value_from_expr_to_string() { - let expr: syn::Expr = syn::parse_str(r#""hello".to_string()"#).unwrap(); - let value = extract_value_from_expr(&expr); - assert_eq!(value, Some(serde_json::Value::String("hello".to_string()))); - } - - #[test] - fn test_extract_value_from_expr_vec_macro() { - let expr: syn::Expr = syn::parse_str("vec![]").unwrap(); - let value = extract_value_from_expr(&expr); - assert_eq!(value, Some(serde_json::Value::Array(vec![]))); - } - - #[test] - fn test_extract_value_from_expr_unsupported() { - // Binary expression is not supported - let expr: syn::Expr = syn::parse_str("1 + 2").unwrap(); - let value = extract_value_from_expr(&expr); - assert!(value.is_none()); - } - - #[test] - fn test_extract_value_from_expr_method_call_non_to_string() { - // Method call that's not to_string() - let expr: syn::Expr = syn::parse_str(r#""hello".len()"#).unwrap(); - let value = extract_value_from_expr(&expr); - assert!(value.is_none()); - } - - #[test] - fn test_extract_value_from_expr_unsupported_literal() { - // Byte literal is not directly supported - let expr: syn::Expr = syn::parse_str("b'a'").unwrap(); - let value = extract_value_from_expr(&expr); - assert!(value.is_none()); - } - - #[test] - fn test_extract_value_from_expr_non_vec_macro() { - // Other macros like println! are not supported - let expr: syn::Expr = syn::parse_str(r#"println!("test")"#).unwrap(); - let value = extract_value_from_expr(&expr); - assert!(value.is_none()); - } - - #[test] - fn test_get_type_default_string() { - let ty: syn::Type = syn::parse_str("String").unwrap(); - let value = utils_get_type_default(&ty); - assert_eq!(value, Some(serde_json::Value::String(String::new()))); - } - - #[test] - fn test_get_type_default_integers() { - for type_name in &["i8", "i16", "i32", "i64", "u8", "u16", "u32", "u64"] { - let ty: syn::Type = syn::parse_str(type_name).unwrap(); - let value = utils_get_type_default(&ty); - assert_eq!( - value, - Some(serde_json::Value::Number(0.into())), - "Failed for type {type_name}" - ); - } - } - - #[test] - fn test_get_type_default_floats() { - for type_name in &["f32", "f64"] { - let ty: syn::Type = syn::parse_str(type_name).unwrap(); - let value = utils_get_type_default(&ty); - assert!(value.is_some(), "Failed for type {type_name}"); - } - } - - #[test] - fn test_get_type_default_bool() { - let ty: syn::Type = syn::parse_str("bool").unwrap(); - let value = utils_get_type_default(&ty); - assert_eq!(value, Some(serde_json::Value::Bool(false))); - } - - #[test] - fn test_get_type_default_unknown() { - let ty: syn::Type = syn::parse_str("CustomType").unwrap(); - let value = utils_get_type_default(&ty); - assert!(value.is_none()); - } - - #[test] - fn test_get_type_default_non_path() { - // Reference type is not a path type - let ty: syn::Type = syn::parse_str("&str").unwrap(); - let value = utils_get_type_default(&ty); - assert!(value.is_none()); - } - - #[test] - fn test_find_function_in_file() { - let file_content = r" -fn foo() {} -fn bar() -> i32 { 42 } -fn baz(x: i32) -> i32 { x } -"; - let file_ast: syn::File = syn::parse_str(file_content).unwrap(); - - assert!(find_function_in_file(&file_ast, "foo").is_some()); - assert!(find_function_in_file(&file_ast, "bar").is_some()); - assert!(find_function_in_file(&file_ast, "baz").is_some()); - assert!(find_function_in_file(&file_ast, "nonexistent").is_none()); - } - - #[test] - fn test_extract_default_value_from_function() { - // Test direct expression return - let func: syn::ItemFn = syn::parse_str( - r" - fn default_value() -> i32 { - 42 - } - ", - ) - .unwrap(); - let value = extract_default_value_from_function(&func); - assert_eq!(value, Some(serde_json::Value::Number(42.into()))); - } - - #[test] - fn test_extract_default_value_from_function_with_return() { - // Test explicit return statement - let func: syn::ItemFn = syn::parse_str( - r#" - fn default_value() -> String { - return "hello".to_string() - } - "#, - ) - .unwrap(); - let value = extract_default_value_from_function(&func); - assert_eq!(value, Some(serde_json::Value::String("hello".to_string()))); - } - - #[test] - fn test_extract_default_value_from_function_empty() { - // Test function with no extractable value - let func: syn::ItemFn = syn::parse_str( - r" - fn default_value() { - let x = 1; - } - ", - ) - .unwrap(); - let value = extract_default_value_from_function(&func); - assert!(value.is_none()); - } - - #[test] - fn test_generate_openapi_with_default_functions() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create a file with struct that has default function - let route_content = r#" -fn default_name() -> String { - "John".to_string() -} - -struct User { - #[serde(default = "default_name")] - name: String, -} - -pub fn get_user() -> User { - User { name: "Alice".to_string() } -} -"#; - let route_file = create_temp_file(&temp_dir, "user.rs", route_content); - - let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "User".to_string(), - definition: r#"struct User { #[serde(default = "default_name")] name: String }"# - .to_string(), - ..Default::default() - }); - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/user".to_string(), - function_name: "get_user".to_string(), - module_path: "test::user".to_string(), - file_path: route_file.to_string_lossy().to_string(), - signature: "fn get_user() -> User".to_string(), - error_status: None, - tags: None, - description: None, - }); - - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - - // Struct should be present - assert!(doc.components.as_ref().unwrap().schemas.is_some()); - let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); - assert!(schemas.contains_key("User")); - } - - #[test] - fn test_generate_openapi_with_simple_default() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - let route_content = r" -struct Config { - #[serde(default)] - enabled: bool, - #[serde(default)] - count: i32, -} - -pub fn get_config() -> Config { - Config { enabled: true, count: 0 } -} -"; - let route_file = create_temp_file(&temp_dir, "config.rs", route_content); - - let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "Config".to_string(), - definition: - r"struct Config { #[serde(default)] enabled: bool, #[serde(default)] count: i32 }" - .to_string(), - ..Default::default() - }); - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/config".to_string(), - function_name: "get_config".to_string(), - module_path: "test::config".to_string(), - file_path: route_file.to_string_lossy().to_string(), - signature: "fn get_config() -> Config".to_string(), - error_status: None, - tags: None, - description: None, - }); - - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - - assert!(doc.components.as_ref().unwrap().schemas.is_some()); - let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); - assert!(schemas.contains_key("Config")); - } - - // ======== Tests for uncovered lines ======== - - #[test] - fn test_fallback_struct_finding_in_route_files() { - // Test line 65: fallback loop that finds struct in any route file when direct search fails - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create TWO route files - struct is in second file, route references it from first - let route1_content = r" -pub fn get_users() -> Vec { - vec![] -} -"; - let route1_file = create_temp_file(&temp_dir, "users.rs", route1_content); - - let route2_content = r#" -fn default_name() -> String { - "Guest".to_string() -} - -struct User { - #[serde(default = "default_name")] - name: String, -} - -pub fn get_user() -> User { - User { name: "Alice".to_string() } -} -"#; - let route2_file = create_temp_file(&temp_dir, "user.rs", route2_content); - - let mut metadata = CollectedMetadata::new(); - // Add struct but point to route1 (which doesn't contain the struct) - // This forces the fallback loop to search other route files - metadata.structs.push(StructMetadata { - name: "User".to_string(), - definition: r#"struct User { #[serde(default = "default_name")] name: String }"# - .to_string(), - ..Default::default() - }); - // Add BOTH routes - the first doesn't contain User struct, so fallback searches the second - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "test::users".to_string(), - file_path: route1_file.to_string_lossy().to_string(), - signature: "fn get_users() -> Vec".to_string(), - error_status: None, - tags: None, - description: None, - }); - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/user".to_string(), - function_name: "get_user".to_string(), - module_path: "test::user".to_string(), - file_path: route2_file.to_string_lossy().to_string(), - signature: "fn get_user() -> User".to_string(), - error_status: None, - tags: None, - description: None, - }); - - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - - // Struct should be found via fallback and processed - assert!(doc.components.as_ref().unwrap().schemas.is_some()); - let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); - assert!(schemas.contains_key("User")); - } - - #[test] - fn test_process_default_functions_with_no_properties() { - // Test line 152: early return when schema.properties is None - // This happens when a struct has no named fields (unit struct or tuple struct) - use vespera_core::schema::Schema; - - let struct_item: syn::ItemStruct = syn::parse_str("struct Empty;").unwrap(); - let file_ast: syn::File = syn::parse_str("fn foo() {}").unwrap(); - let mut schema = Schema::object(); - schema.properties = None; // Explicitly set to None - - // This should return early without panic - process_default_functions(&struct_item, &file_ast, &mut schema, &BTreeMap::new()); - - // Schema should remain unchanged - assert!(schema.properties.is_none()); - } - - #[test] - fn test_extract_value_from_expr_int_parse_failure() { - // Test line 253: int parse failure (overflow) - // Create an integer literal that's too large to parse as i64 - // Use a literal that syn will parse but i64::parse will fail on - let expr: syn::Expr = syn::parse_str("999999999999999999999999999999").unwrap(); - let value = extract_value_from_expr(&expr); - assert!(value.is_none()); - } - - #[test] - fn test_extract_value_from_expr_float_parse_failure() { - // Test line 260: float parse failure - // Create a float literal that's too large/invalid - let expr: syn::Expr = syn::parse_str("1e999999").unwrap(); - let value = extract_value_from_expr(&expr); - // This may parse successfully to infinity or fail - either way should handle it - // The important thing is no panic - let _ = value; - } - - #[test] - fn test_extract_value_from_expr_method_call_with_nested_receiver() { - // Test lines 275-276: recursive extraction from method call receiver - // When receiver is not a direct string literal, it tries to extract recursively - // But the recursive call also won't find a Lit, so it returns None - // This test verifies the recursive path is exercised (line 275-276) - let expr: syn::Expr = syn::parse_str(r#"("hello").to_string()"#).unwrap(); - let value = extract_value_from_expr(&expr); - // The receiver is a Paren expression - recursive call is made but returns None - // because Paren is not handled in the match - assert!(value.is_none()); - } - - #[test] - fn test_extract_value_from_expr_method_call_with_non_literal_receiver() { - // Test lines 275-276: recursive extraction fails for non-literal - let expr: syn::Expr = syn::parse_str(r"some_var.to_string()").unwrap(); - let value = extract_value_from_expr(&expr); - // Cannot extract value from a variable - assert!(value.is_none()); - } - - #[test] - fn test_extract_value_from_expr_method_call_chained_to_string() { - // Test lines 275-276: another case where recursive extraction is attempted - // Chained method calls: 42.to_string() has int literal as receiver - let expr: syn::Expr = syn::parse_str(r"42.to_string()").unwrap(); - let value = extract_value_from_expr(&expr); - // Line 275 recursive call extracts 42 as Number, then line 276 returns it - assert_eq!(value, Some(serde_json::Value::Number(42.into()))); - } - - #[test] - fn test_get_type_default_empty_path_segments() { - // Test empty path segments returns None - // Create a type with empty path segments - - // Use parse to create a valid type, then we verify the normal path works - let ty: syn::Type = syn::parse_str("::String").unwrap(); - // This has segments, so it should work - let value = utils_get_type_default(&ty); - // Global path ::String still has "String" as last segment - assert!(value.is_some()); - - // Test reference type (non-path type) - let ref_ty: syn::Type = syn::parse_str("&str").unwrap(); - let ref_value = utils_get_type_default(&ref_ty); - // Reference is not a Path type, so returns None - assert!(ref_value.is_none()); - } - - #[test] - fn test_get_type_default_tuple_type() { - // Test non-Path type returns None - let ty: syn::Type = syn::parse_str("(i32, String)").unwrap(); - let value = utils_get_type_default(&ty); - assert!(value.is_none()); - } - - #[test] - fn test_get_type_default_array_type() { - // Test array type returns None - let ty: syn::Type = syn::parse_str("[i32; 3]").unwrap(); - let value = utils_get_type_default(&ty); - assert!(value.is_none()); - } - - #[test] - fn test_build_path_items_unknown_http_method() { - // Test lines 131-134: route with unknown HTTP method is skipped - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - let route_content = r#" -pub fn get_users() -> String { - "users".to_string() -} -"#; - let route_file = create_temp_file(&temp_dir, "users.rs", route_content); - - let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "INVALID".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "test::users".to_string(), - file_path: route_file.to_string_lossy().to_string(), - signature: "fn get_users() -> String".to_string(), - error_status: None, - tags: None, - description: None, - }); - - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - - // Route with unknown HTTP method should be skipped entirely - assert!( - doc.paths.is_empty(), - "Route with unknown HTTP method should be skipped" - ); - } - - #[test] - fn test_build_path_items_unknown_method_skipped_valid_kept() { - // Test that unknown methods are skipped while valid routes are kept - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - let route_content = r#" -pub fn get_users() -> String { - "users".to_string() -} - -pub fn create_users() -> String { - "created".to_string() -} -"#; - let route_file = create_temp_file(&temp_dir, "users.rs", route_content); - let file_path = route_file.to_string_lossy().to_string(); - - let mut metadata = CollectedMetadata::new(); - // Invalid method route - metadata.routes.push(RouteMetadata { - method: "CONNECT".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "test::users".to_string(), - file_path: file_path.clone(), - signature: "fn get_users() -> String".to_string(), - error_status: None, - tags: None, - description: None, - }); - // Valid method route - metadata.routes.push(RouteMetadata { - method: "POST".to_string(), - path: "/users".to_string(), - function_name: "create_users".to_string(), - module_path: "test::users".to_string(), - file_path, - signature: "fn create_users() -> String".to_string(), - error_status: None, - tags: None, - description: None, - }); - - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - - // Only the valid POST route should appear - assert_eq!(doc.paths.len(), 1); - let path_item = doc.paths.get("/users").unwrap(); - assert!( - path_item.post.is_some(), - "Valid POST route should be present" - ); - assert!( - path_item.get.is_none(), - "Invalid method route should be skipped" - ); - } - - #[test] - fn test_generate_openapi_with_unparseable_definition() { - // Test line 42: syn::parse_str fails with invalid Rust syntax - // This triggers the `continue` branch when parsing fails - let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "Invalid".to_string(), - // Invalid Rust syntax - cannot be parsed by syn - definition: "struct { invalid syntax {{{{".to_string(), - include_in_openapi: true, - field_defaults: BTreeMap::new(), - }); - - // Should gracefully skip unparseable definitions - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - // The unparseable definition should be skipped - assert!(doc.components.is_none() || doc.components.as_ref().unwrap().schemas.is_none()); - } - - // ======== Tests for set_property_default helper ======== - - #[test] - fn test_set_property_default_on_inline_schema() { - use vespera_core::schema::{Schema, SchemaRef}; - - let mut properties = BTreeMap::new(); - let mut schema = Schema::object(); - schema.default = None; - properties.insert("name".to_string(), SchemaRef::Inline(Box::new(schema))); - - set_property_default( - &mut properties, - "name", - serde_json::Value::String("Alice".to_string()), - ); - - if let Some(SchemaRef::Inline(prop)) = properties.get("name") { - assert_eq!( - prop.default, - Some(serde_json::Value::String("Alice".to_string())) - ); - } else { - panic!("Expected Inline schema"); - } - } - - #[test] - fn test_set_property_default_does_not_overwrite_existing() { - use vespera_core::schema::{Schema, SchemaRef}; - - let mut properties = BTreeMap::new(); - let mut schema = Schema::object(); - schema.default = Some(serde_json::Value::String("existing".to_string())); - properties.insert("name".to_string(), SchemaRef::Inline(Box::new(schema))); - - set_property_default( - &mut properties, - "name", - serde_json::Value::String("new".to_string()), - ); - - if let Some(SchemaRef::Inline(prop)) = properties.get("name") { - assert_eq!( - prop.default, - Some(serde_json::Value::String("existing".to_string())), - "Should NOT overwrite existing default" - ); - } else { - panic!("Expected Inline schema"); - } - } - - #[test] - fn test_set_property_default_skips_ref_schema() { - use vespera_core::schema::{Reference, SchemaRef}; - - let mut properties = BTreeMap::new(); - properties.insert( - "user".to_string(), - SchemaRef::Ref(Reference::schema("User")), - ); - - // Should silently no-op (Ref variants have no default field) - set_property_default( - &mut properties, - "user", - serde_json::Value::String("ignored".to_string()), - ); - - assert!( - matches!(properties.get("user"), Some(SchemaRef::Ref(_))), - "Should remain a Ref variant" - ); - } - - #[test] - fn test_set_property_default_skips_missing_property() { - let mut properties = BTreeMap::new(); - - // Should silently no-op (property doesn't exist) - set_property_default( - &mut properties, - "nonexistent", - serde_json::Value::Number(42.into()), - ); - - assert!(properties.is_empty(), "Should not insert new properties"); - } - - #[test] - fn test_extract_schema_default_attr_with_value() { - let attrs: Vec = vec![syn::parse_quote!(#[schema(default = "42")])]; - let result = extract_schema_default_attr(&attrs); - assert_eq!(result, Some("42".to_string())); - } - - #[test] - fn test_extract_schema_default_attr_no_default() { - let attrs: Vec = vec![syn::parse_quote!(#[schema(rename = "foo")])]; - let result = extract_schema_default_attr(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_extract_schema_default_attr_non_schema() { - let attrs: Vec = vec![syn::parse_quote!(#[serde(default)])]; - let result = extract_schema_default_attr(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_parse_default_string_to_json_value_integer() { - let result = parse_default_string_to_json_value("42"); - assert_eq!(result, serde_json::Value::Number(42.into())); - } - - #[test] - fn test_parse_default_string_to_json_value_float() { - let result = parse_default_string_to_json_value("2.72"); - assert_eq!(result, serde_json::json!(2.72)); - } - - #[test] - fn test_parse_default_string_to_json_value_bool() { - let result = parse_default_string_to_json_value("true"); - assert_eq!(result, serde_json::Value::Bool(true)); - } - - #[test] - fn test_parse_default_string_to_json_value_string_fallback() { - let result = parse_default_string_to_json_value("hello world"); - assert_eq!(result, serde_json::Value::String("hello world".to_string())); - } - - #[test] - fn test_process_default_functions_with_schema_default_attr() { - use vespera_core::schema::{Schema, SchemaRef}; - - let file_ast: syn::File = syn::parse_str("").unwrap(); - let struct_item: syn::ItemStruct = - syn::parse_str(r#"pub struct Test { #[schema(default = "100")] pub count: i32 }"#) - .unwrap(); - let mut schema = Schema::object(); - let props = schema.properties.get_or_insert_with(BTreeMap::new); - props.insert( - "count".to_string(), - SchemaRef::Inline(Box::new(Schema::integer())), - ); - process_default_functions(&struct_item, &file_ast, &mut schema, &BTreeMap::new()); - if let Some(SchemaRef::Inline(prop_schema)) = - schema.properties.as_ref().unwrap().get("count") - { - assert_eq!(prop_schema.default, Some(serde_json::json!(100))); - } else { - panic!("Expected inline schema with default"); - } - } - - #[test] - fn test_generate_openapi_route_function_not_in_ast() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let route_content = "pub fn get_items() -> String { \"items\".to_string() }\n"; - let route_file = create_temp_file(&temp_dir, "users.rs", route_content); - - let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "test::users".to_string(), - file_path: route_file.to_string_lossy().to_string(), - signature: "fn get_users() -> String".to_string(), - error_status: None, - tags: None, - description: None, - }); - - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - assert!( - doc.paths.is_empty(), - "Route with non-matching function should be skipped" - ); - } - - #[test] - fn test_generate_openapi_with_route_storage_fast_path() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let route_content = r#" -pub fn get_users() -> String { - "users".to_string() -} -"#; - let route_file = create_temp_file(&temp_dir, "users.rs", route_content); - - let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "test::users".to_string(), - file_path: route_file.to_string_lossy().to_string(), - signature: "fn get_users() -> String".to_string(), - error_status: None, - tags: None, - description: None, - }); - - // Provide route_storage with matching fn_name -> exercises fast path (line 155) - let route_storage = vec![StoredRouteInfo { - fn_name: "get_users".to_string(), - method: Some("get".to_string()), - custom_path: None, - error_status: None, - tags: None, - description: None, - fn_item_str: "pub fn get_users() -> String { \"users\".to_string() }".to_string(), - file_path: None, - }]; - - let doc = - generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &route_storage); - - assert!(doc.paths.contains_key("/users")); - let path_item = doc.paths.get("/users").unwrap(); - assert!(path_item.get.is_some()); - let operation = path_item.get.as_ref().unwrap(); - assert_eq!(operation.operation_id, Some("get_users".to_string())); - } - - #[test] - fn test_generate_openapi_with_stored_field_defaults() { - let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "Config".to_string(), - definition: "struct Config { count: i32, name: String }".to_string(), - include_in_openapi: true, - field_defaults: BTreeMap::from([ - ("count".to_string(), serde_json::json!(42)), - ("name".to_string(), serde_json::json!("default_name")), - ]), - }); - - // Need a route so the file_cache has at least one entry for the fallback in parse_component_schemas - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let route_content = r" -struct Config { count: i32, name: String } -pub fn get_config() -> Config { Config { count: 0, name: String::new() } } -"; - let route_file = create_temp_file(&temp_dir, "config.rs", route_content); - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/config".to_string(), - function_name: "get_config".to_string(), - module_path: "test::config".to_string(), - file_path: route_file.to_string_lossy().to_string(), - signature: "fn get_config() -> Config".to_string(), - error_status: None, - tags: None, - description: None, - }); - - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - - // Verify schema exists - assert!(doc.components.as_ref().unwrap().schemas.is_some()); - let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); - let config_schema = schemas.get("Config").expect("Config schema should exist"); - - // Verify default values were set from stored_defaults (Priority 0 path) - if let Some(props) = &config_schema.properties { - if let Some(vespera_core::schema::SchemaRef::Inline(count_schema)) = props.get("count") - { - assert_eq!( - count_schema.default, - Some(serde_json::json!(42)), - "count should have default 42 from stored_defaults" - ); - } - if let Some(vespera_core::schema::SchemaRef::Inline(name_schema)) = props.get("name") { - assert_eq!( - name_schema.default, - Some(serde_json::json!("default_name")), - "name should have default from stored_defaults" - ); - } - } - } } diff --git a/crates/vespera_macro/src/openapi_generator/component_schemas.rs b/crates/vespera_macro/src/openapi_generator/component_schemas.rs new file mode 100644 index 00000000..13352b7a --- /dev/null +++ b/crates/vespera_macro/src/openapi_generator/component_schemas.rs @@ -0,0 +1,446 @@ +//! Component schema lookup, file-cache indexing, and schema parsing. + +use std::{ + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, + path::Path, +}; + +use crate::{ + metadata::CollectedMetadata, + openapi_generator::{defaults::process_default_functions, paths::parallel_filter_map}, + parser::{parse_enum_to_schema, parse_struct_to_schema}, +}; + +/// Build schema name and definition lookup maps from metadata. +/// +/// Registers ALL structs (including `include_in_openapi: false`) so that +/// `schema_type!` generated types can reference them. +pub(super) fn build_schema_lookups( + metadata: &CollectedMetadata, +) -> (HashSet, HashMap) { + let mut known_schema_names = HashSet::with_capacity(metadata.structs.len()); + let mut struct_definitions = HashMap::with_capacity(metadata.structs.len()); + + for struct_meta in &metadata.structs { + struct_definitions.insert(struct_meta.name.clone(), struct_meta.definition.clone()); + known_schema_names.insert(struct_meta.name.clone()); + } + + (known_schema_names, struct_definitions) +} + +/// Build file AST cache — parse each unique route file exactly once. +/// +/// Deduplicates file paths first, then parses each file a single time. +/// This eliminates redundant file I/O when multiple routes share a source file. +pub(super) fn build_file_cache(metadata: &CollectedMetadata) -> HashMap { + let unique_paths: BTreeSet<&str> = metadata + .routes + .iter() + .map(|r| r.file_path.as_str()) + .collect(); + let mut cache = HashMap::with_capacity(unique_paths.len()); + for path in unique_paths { + if let Some(ast) = crate::schema_macro::file_cache::get_parsed_file(Path::new(path)) { + cache.insert(path.to_string(), ast); + } + } + cache +} + +/// Build struct name → file path index from cached file ASTs. +/// +/// Enables O(1) lookup of which file contains a given struct definition, +/// replacing the previous O(routes × file_read) linear scan. +pub(super) fn build_struct_file_index( + file_cache: &HashMap, +) -> HashMap { + let mut index = HashMap::with_capacity(file_cache.len() * 4); + for (path, ast) in file_cache { + for item in &ast.items { + if let syn::Item::Struct(s) = item { + index.insert(s.ident.to_string(), path.as_str()); + } + } + } + index +} + +/// Parse struct and enum definitions into `OpenAPI` component schemas. +/// +/// Only includes structs where `include_in_openapi` is true +/// (i.e., from `#[derive(Schema)]`, not from cross-file lookup). +/// Also processes `#[serde(default)]` attributes to extract default values. +/// +/// Uses pre-built `file_cache` and `struct_file_index` for O(1) file lookups +/// instead of scanning all route files per struct. +pub(super) fn parse_component_schemas( + metadata: &CollectedMetadata, + known_schema_names: &HashSet, + struct_definitions: &HashMap, + file_cache: &HashMap, + struct_file_index: &HashMap, +) -> BTreeMap { + // Parse a definition string and build its schema, applying the + // default-value pipeline. `file_ast` is only needed for the + // `#[serde(default = "fn_name")]` fallback (Priority 2) — the + // pre-extracted SCHEMA_STORAGE defaults, `#[schema(default)]` + // attributes, and type defaults apply even without an AST (the + // collector fast path skips parsing, leaving `file_cache` empty). + let build_one = |struct_meta: &crate::metadata::StructMetadata, + file_ast: Option<&syn::File>| + -> Option<(String, vespera_core::schema::Schema)> { + let parsed = syn::parse_str::(&struct_meta.definition).ok()?; + let mut schema = match &parsed { + syn::Item::Struct(struct_item) => { + parse_struct_to_schema(struct_item, known_schema_names, struct_definitions) + } + syn::Item::Enum(enum_item) => { + parse_enum_to_schema(enum_item, known_schema_names, struct_definitions) + } + _ => return None, + }; + if let syn::Item::Struct(struct_item) = &parsed { + process_default_functions( + struct_item, + file_ast, + &mut schema, + &struct_meta.field_defaults, + ); + } + Some((struct_meta.name.clone(), schema)) + }; + + // Partition: structs whose file AST is reachable need the + // (non-`Send`) AST for Priority-2 default extraction and run on + // this thread; everything else parses + builds on workers + // returning plain `Schema` data. + let mut ast_backed: Vec<(&crate::metadata::StructMetadata, &syn::File)> = Vec::new(); + let mut parallel_jobs: Vec<&crate::metadata::StructMetadata> = Vec::new(); + for struct_meta in metadata.structs.iter().filter(|s| s.include_in_openapi) { + let file_ast = struct_file_index + .get(&struct_meta.name) + .and_then(|path| file_cache.get(*path)) + .or_else(|| { + metadata + .routes + .first() + .and_then(|r| file_cache.get(&r.file_path)) + }); + match file_ast { + Some(ast) => ast_backed.push((struct_meta, ast)), + None => parallel_jobs.push(struct_meta), + } + } + + let mut schemas = BTreeMap::new(); + for (name, schema) in parallel_filter_map( + ¶llel_jobs, + &|meta: &&crate::metadata::StructMetadata| build_one(meta, None), + ) { + schemas.insert(name, schema); + } + for (struct_meta, ast) in ast_backed { + if let Some((name, schema)) = build_one(struct_meta, Some(ast)) { + schemas.insert(name, schema); + } + } + + schemas +} + +#[cfg(test)] +mod tests { + use std::{collections::BTreeMap, fs, path::PathBuf}; + + use rstest::rstest; + use serde_json::{Value, json}; + use tempfile::TempDir; + use vespera_core::schema::SchemaRef; + + use super::*; + use crate::{ + metadata::{CollectedMetadata, RouteMetadata, StructMetadata}, + openapi_generator::generate_openapi_doc_with_metadata, + }; + + fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> PathBuf { + let file_path = dir.path().join(filename); + fs::write(&file_path, content).expect("Failed to write temp file"); + file_path + } + + fn route_meta(path: &str, fn_name: &str, file_path: &str) -> RouteMetadata { + RouteMetadata { + method: "GET".to_string(), + path: path.to_string(), + function_name: fn_name.to_string(), + module_path: format!("test::{fn_name}"), + file_path: file_path.to_string(), + error_status: None, + tags: None, + description: None, + } + } + + fn struct_meta(name: &str, definition: &str) -> StructMetadata { + StructMetadata { + name: name.to_string(), + definition: definition.to_string(), + ..Default::default() + } + } + + fn schemas( + doc: &vespera_core::openapi::OpenApi, + ) -> &BTreeMap { + doc.components + .as_ref() + .and_then(|c| c.schemas.as_ref()) + .expect("schemas present") + } + + fn property_default<'a>( + schema: &'a vespera_core::schema::Schema, + field_name: &str, + ) -> Option<&'a Value> { + let SchemaRef::Inline(prop_schema) = schema.properties.as_ref()?.get(field_name)? else { + return None; + }; + prop_schema.default.as_ref() + } + + #[test] + fn schema_lookups_include_hidden_structs_for_references() { + let mut metadata = CollectedMetadata::new(); + metadata.structs.push(StructMetadata { + name: "Hidden".to_string(), + definition: "struct Hidden { id: i32 }".to_string(), + include_in_openapi: false, + field_defaults: BTreeMap::new(), + }); + + let (known_schema_names, struct_definitions) = build_schema_lookups(&metadata); + + assert!(known_schema_names.contains("Hidden")); + assert_eq!( + struct_definitions.get("Hidden").unwrap(), + "struct Hidden { id: i32 }" + ); + } + + #[rstest] + #[case::struct_schema("User", "struct User { id: i32, name: String }")] + #[case::enum_schema("Status", "enum Status { Active, Inactive, Pending }")] + #[case::enum_with_data( + "Message", + "enum Message { Text(String), User { id: i32, name: String } }" + )] + fn valid_component_definitions_are_included(#[case] name: &str, #[case] definition: &str) { + let mut metadata = CollectedMetadata::new(); + metadata.structs.push(struct_meta(name, definition)); + + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); + + assert!(schemas(&doc).contains_key(name)); + } + + #[rstest] + #[case::non_struct_non_enum("Config", "const CONFIG: i32 = 42;")] + #[case::unparseable_definition("Invalid", "struct { invalid syntax {{{{")] + fn invalid_component_definitions_are_skipped(#[case] name: &str, #[case] definition: &str) { + let mut metadata = CollectedMetadata::new(); + metadata.structs.push(StructMetadata { + name: name.to_string(), + definition: definition.to_string(), + include_in_openapi: true, + field_defaults: BTreeMap::new(), + }); + + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); + + assert!(doc.components.is_none() || doc.components.as_ref().unwrap().schemas.is_none()); + } + + #[test] + fn enum_schema_and_route_are_generated_together() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let route_file = create_temp_file( + &temp_dir, + "status_route.rs", + "pub fn get_status() -> Status { Status::Active }", + ); + + let mut metadata = CollectedMetadata::new(); + metadata + .structs + .push(struct_meta("Status", "enum Status { Active, Inactive }")); + metadata.routes.push(route_meta( + "/status", + "get_status", + &route_file.to_string_lossy(), + )); + + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); + + assert!(schemas(&doc).contains_key("Status")); + assert!(doc.paths.contains_key("/status")); + } + + #[test] + fn serde_default_function_sets_property_default() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let route_file = create_temp_file( + &temp_dir, + "user.rs", + r#" +fn default_name() -> String { "John".to_string() } + +struct User { + #[serde(default = "default_name")] + name: String, +} + +pub fn get_user() -> User { User { name: "Alice".to_string() } } +"#, + ); + + let mut metadata = CollectedMetadata::new(); + metadata.structs.push(struct_meta( + "User", + r#"struct User { #[serde(default = "default_name")] name: String }"#, + )); + metadata.routes.push(route_meta( + "/user", + "get_user", + &route_file.to_string_lossy(), + )); + + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); + let user_schema = schemas(&doc).get("User").expect("User schema"); + + assert_eq!(property_default(user_schema, "name"), Some(&json!("John"))); + } + + #[test] + fn serde_simple_default_uses_type_defaults() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let route_file = create_temp_file( + &temp_dir, + "config.rs", + r" +struct Config { + #[serde(default)] + enabled: bool, + #[serde(default)] + count: i32, +} + +pub fn get_config() -> Config { Config { enabled: true, count: 0 } } +", + ); + + let mut metadata = CollectedMetadata::new(); + metadata.structs.push(struct_meta( + "Config", + r"struct Config { #[serde(default)] enabled: bool, #[serde(default)] count: i32 }", + )); + metadata.routes.push(route_meta( + "/config", + "get_config", + &route_file.to_string_lossy(), + )); + + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); + let config_schema = schemas(&doc).get("Config").expect("Config schema"); + + assert_eq!( + property_default(config_schema, "enabled"), + Some(&json!(false)) + ); + assert_eq!(property_default(config_schema, "count"), Some(&json!(0))); + } + + #[test] + fn struct_file_index_finds_struct_in_another_route_file() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let route1_file = create_temp_file( + &temp_dir, + "users.rs", + "pub fn get_users() -> Vec { vec![] }", + ); + let route2_file = create_temp_file( + &temp_dir, + "user.rs", + r#" +fn default_name() -> String { "Guest".to_string() } + +struct User { + #[serde(default = "default_name")] + name: String, +} + +pub fn get_user() -> User { User { name: "Alice".to_string() } } +"#, + ); + + let mut metadata = CollectedMetadata::new(); + metadata.structs.push(struct_meta( + "User", + r#"struct User { #[serde(default = "default_name")] name: String }"#, + )); + metadata.routes.push(route_meta( + "/users", + "get_users", + &route1_file.to_string_lossy(), + )); + metadata.routes.push(route_meta( + "/user", + "get_user", + &route2_file.to_string_lossy(), + )); + + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); + let user_schema = schemas(&doc).get("User").expect("User schema"); + + assert_eq!(property_default(user_schema, "name"), Some(&json!("Guest"))); + } + + #[test] + fn stored_field_defaults_have_highest_priority() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let route_file = create_temp_file( + &temp_dir, + "config.rs", + r" +struct Config { count: i32, name: String } +pub fn get_config() -> Config { Config { count: 0, name: String::new() } } +", + ); + + let mut metadata = CollectedMetadata::new(); + metadata.structs.push(StructMetadata { + name: "Config".to_string(), + definition: "struct Config { count: i32, name: String }".to_string(), + include_in_openapi: true, + field_defaults: BTreeMap::from([ + ("count".to_string(), json!(42)), + ("name".to_string(), json!("default_name")), + ]), + }); + metadata.routes.push(route_meta( + "/config", + "get_config", + &route_file.to_string_lossy(), + )); + + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); + let config_schema = schemas(&doc).get("Config").expect("Config schema"); + + assert_eq!(property_default(config_schema, "count"), Some(&json!(42))); + assert_eq!( + property_default(config_schema, "name"), + Some(&json!("default_name")) + ); + } +} diff --git a/crates/vespera_macro/src/openapi_generator/defaults.rs b/crates/vespera_macro/src/openapi_generator/defaults.rs new file mode 100644 index 00000000..b26b2a3c --- /dev/null +++ b/crates/vespera_macro/src/openapi_generator/defaults.rs @@ -0,0 +1,497 @@ +//! Default-value extraction for OpenAPI schema generation. +//! +//! Handles the three sources of struct field defaults: +//! 1. Pre-extracted `SCHEMA_STORAGE` defaults (populated by `#[derive(Schema)]`) +//! 2. `#[schema(default = "...")]` attributes (generated by `schema_type!`) +//! 3. `#[serde(default)]` / `#[serde(default = "fn_name")]` attributes +//! (the function variant needs a parsed file AST) + +use std::collections::BTreeMap; + +use crate::{ + parser::{ + extract_default, extract_field_rename, extract_rename_all, rename_field, + strip_raw_prefix_owned, + }, + schema_macro::type_utils::get_type_default as utils_get_type_default, +}; + +/// Set the default value on an inline property schema, if not already set. +/// +/// Looks up `field_name` in the properties map. If found as an inline schema +/// and the schema has no existing default, sets `value` as the default. +pub(super) fn set_property_default( + properties: &mut BTreeMap, + field_name: &str, + value: serde_json::Value, +) { + use vespera_core::schema::SchemaRef; + + if let Some(SchemaRef::Inline(prop_schema)) = properties.get_mut(field_name) + && prop_schema.default.is_none() + { + prop_schema.default = Some(value); + } +} + +/// Process default functions for struct fields +/// This function extracts default values from: +/// 1. `#[schema(default = "value")]` attributes (generated by `schema_type!` from `sea_orm(default_value)`) +/// 2. `#[serde(default = "function_name")]` by finding the function in the file AST +/// 3. `#[serde(default)]` by using type-specific defaults +pub(super) fn process_default_functions( + struct_item: &syn::ItemStruct, + file_ast: Option<&syn::File>, + schema: &mut vespera_core::schema::Schema, + stored_defaults: &BTreeMap, +) { + use syn::Fields; + + // Extract rename_all from struct level + let struct_rename_all = extract_rename_all(&struct_item.attrs); + + // Get properties from schema + let Some(properties) = &mut schema.properties else { + return; + }; + + // Process each field in the struct + if let Fields::Named(fields_named) = &struct_item.fields { + for field in &fields_named.named { + let rust_field_name = field.ident.as_ref().map_or_else( + || "unknown".to_string(), + |i| strip_raw_prefix_owned(i.to_string()), + ); + let field_name = extract_field_rename(&field.attrs) + .unwrap_or_else(|| rename_field(&rust_field_name, struct_rename_all.as_deref())); + + // Priority 0: Pre-extracted defaults from SCHEMA_STORAGE (populated by #[derive(Schema)]) + if let Some(value) = stored_defaults.get(&rust_field_name) { + set_property_default(properties, &field_name, value.clone()); + continue; + } + + // Priority 1: #[schema(default = "value")] from schema_type! macro + if let Some(default_str) = extract_schema_default_attr(&field.attrs) { + let value = parse_default_string_to_json_value(&default_str); + set_property_default(properties, &field_name, value); + continue; + } + + // Priority 2: #[serde(default)] / #[serde(default = "fn")] + let default_info = match extract_default(&field.attrs) { + Some(Some(func_name)) => func_name, // default = "function_name" + Some(None) => { + // Simple default (no function) - we can set type-specific defaults + if let Some(default_value) = utils_get_type_default(&field.ty) { + set_property_default(properties, &field_name, default_value); + } + continue; + } + None => continue, // No default attribute + }; + + // Find the function in the file AST and extract default + // value — Priority 2 is the only step that needs the AST, + // so it degrades gracefully when none is available. + if let Some(func_item) = + file_ast.and_then(|ast| find_function_in_file(ast, &default_info)) + && let Some(default_value) = extract_default_value_from_function(func_item) + { + set_property_default(properties, &field_name, default_value); + } + } + } +} + +/// Extract `default` value from `#[schema(default = "...")]` field attribute. +/// +/// This attribute is generated by `schema_type!` when converting `sea_orm(default_value)`. +/// It carries the raw default value string for OpenAPI schema generation. +pub(super) fn extract_schema_default_attr(attrs: &[syn::Attribute]) -> Option { + attrs + .iter() + .filter(|attr| attr.path().is_ident("schema")) + .find_map(|attr| { + let mut default_value = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("default") { + let value = meta.value()?; + let lit: syn::LitStr = value.parse()?; + default_value = Some(lit.value()); + } + Ok(()) + }); + default_value + }) +} + +/// Parse a default value string into the appropriate `serde_json::Value`. +/// +/// Tries to infer the JSON type: integer → number → bool → string (fallback). +pub(super) fn parse_default_string_to_json_value(value: &str) -> serde_json::Value { + // Try integer first + if let Ok(n) = value.parse::() { + return serde_json::Value::Number(n.into()); + } + // Try float + if let Ok(f) = value.parse::() + && let Some(n) = serde_json::Number::from_f64(f) + { + return serde_json::Value::Number(n); + } + // Try bool + if let Ok(b) = value.parse::() { + return serde_json::Value::Bool(b); + } + // Fallback to string + serde_json::Value::String(value.to_string()) +} + +/// Find a function by name in the file AST +pub fn find_function_in_file<'a>( + file_ast: &'a syn::File, + function_name: &str, +) -> Option<&'a syn::ItemFn> { + file_ast.items.iter().find_map(|item| match item { + syn::Item::Fn(fn_item) if fn_item.sig.ident == function_name => Some(fn_item), + _ => None, + }) +} + +/// Extract default value from function body +/// This tries to extract literal values from common patterns like: +/// - "`value".to_string()` -> "value" +/// - 42 -> 42 +/// - true -> true +/// - vec![] -> [] +pub fn extract_default_value_from_function(func: &syn::ItemFn) -> Option { + // Try to find return statement or expression + for stmt in &func.block.stmts { + if let syn::Stmt::Expr(expr, _) = stmt { + // Direct expression (like "value".to_string()) + if let Some(value) = extract_value_from_expr(expr) { + return Some(value); + } + // Or return statement + if let syn::Expr::Return(ret) = expr + && let Some(expr) = &ret.expr + && let Some(value) = extract_value_from_expr(expr) + { + return Some(value); + } + } + } + + None +} + +/// Extract value from expression +pub(super) fn extract_value_from_expr(expr: &syn::Expr) -> Option { + use syn::{Expr, ExprLit, ExprMacro, Lit}; + + match expr { + // Literal values + Expr::Lit(ExprLit { lit, .. }) => match lit { + Lit::Str(s) => Some(serde_json::Value::String(s.value())), + Lit::Int(i) => i + .base10_parse::() + .ok() + .map(|v| serde_json::Value::Number(v.into())), + Lit::Float(f) => f + .base10_parse::() + .ok() + .and_then(serde_json::Number::from_f64) + .map(serde_json::Value::Number), + Lit::Bool(b) => Some(serde_json::Value::Bool(b.value)), + _ => None, + }, + // Method calls like "value".to_string() + Expr::MethodCall(method_call) => { + if method_call.method == "to_string" { + // Get the receiver (the string literal) + // Try direct match first + if let Expr::Lit(ExprLit { + lit: Lit::Str(s), .. + }) = method_call.receiver.as_ref() + { + return Some(serde_json::Value::String(s.value())); + } + // Try to extract from nested expressions (e.g., if the receiver is wrapped) + if let Some(value) = extract_value_from_expr(method_call.receiver.as_ref()) { + return Some(value); + } + } + None + } + // Macro calls like vec![] + Expr::Macro(ExprMacro { mac, .. }) => { + if mac.path.is_ident("vec") { + // Try to parse vec![] as empty array + return Some(serde_json::Value::Array(vec![])); + } + None + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use rstest::rstest; + use serde_json::{Value, json}; + use vespera_core::schema::{Reference, Schema, SchemaRef}; + + use super::*; + + fn parse_expr(src: &str) -> syn::Expr { + syn::parse_str(src).expect("expr parses") + } + + fn parse_fn(src: &str) -> syn::ItemFn { + syn::parse_str(src).expect("fn parses") + } + + fn parse_type(src: &str) -> syn::Type { + syn::parse_str(src).expect("type parses") + } + + // ---------- extract_value_from_expr ---------- + + #[rstest] + #[case::int("42", Some(Value::Number(42.into())))] + #[case::string(r#""hello""#, Some(Value::String("hello".to_string())))] + #[case::bool_true("true", Some(Value::Bool(true)))] + #[case::bool_false("false", Some(Value::Bool(false)))] + #[case::to_string(r#""hello".to_string()"#, Some(Value::String("hello".to_string())))] + #[case::vec_macro("vec![]", Some(Value::Array(vec![])))] + #[case::int_to_string("42.to_string()", Some(Value::Number(42.into())))] + #[case::binary_unsupported("1 + 2", None)] + #[case::method_call_non_to_string(r#""hello".len()"#, None)] + #[case::byte_lit_unsupported("b'a'", None)] + #[case::non_vec_macro(r#"println!("test")"#, None)] + #[case::nested_paren_receiver(r#"("hello").to_string()"#, None)] + #[case::non_literal_receiver("some_var.to_string()", None)] + #[case::int_overflow("999999999999999999999999999999", None)] + fn extract_value_from_expr_cases(#[case] src: &str, #[case] expected: Option) { + assert_eq!(extract_value_from_expr(&parse_expr(src)), expected); + } + + #[test] + fn extract_value_from_expr_float_in_range() { + // Float equality probe is separate — 12.34 round-trips but the assertion + // needs a tolerance check rather than direct equality. + let value = extract_value_from_expr(&parse_expr("12.34")); + match value { + Some(Value::Number(n)) => assert!((n.as_f64().unwrap() - 12.34).abs() < 0.001), + other => panic!("expected number, got {other:?}"), + } + } + + #[test] + fn extract_value_from_expr_float_parse_failure_does_not_panic() { + // 1e999999 may parse to infinity or fail — either way the call must not panic. + let _ = extract_value_from_expr(&parse_expr("1e999999")); + } + + // ---------- get_type_default (re-exported helper) ---------- + + #[rstest] + #[case::string("String", Some(Value::String(String::new())))] + #[case::i8("i8", Some(Value::Number(0.into())))] + #[case::i16("i16", Some(Value::Number(0.into())))] + #[case::i32("i32", Some(Value::Number(0.into())))] + #[case::i64("i64", Some(Value::Number(0.into())))] + #[case::u8("u8", Some(Value::Number(0.into())))] + #[case::u16("u16", Some(Value::Number(0.into())))] + #[case::u32("u32", Some(Value::Number(0.into())))] + #[case::u64("u64", Some(Value::Number(0.into())))] + #[case::bool("bool", Some(Value::Bool(false)))] + #[case::unknown_custom("CustomType", None)] + #[case::non_path_ref("&str", None)] + #[case::tuple("(i32, String)", None)] + #[case::array("[i32; 3]", None)] + fn get_type_default_cases(#[case] src: &str, #[case] expected: Option) { + assert_eq!(utils_get_type_default(&parse_type(src)), expected); + } + + #[rstest] + #[case::f32("f32")] + #[case::f64("f64")] + fn get_type_default_floats_present(#[case] src: &str) { + assert!(utils_get_type_default(&parse_type(src)).is_some()); + } + + #[test] + fn get_type_default_global_path_still_resolved() { + // `::String` has a leading colon-colon but the last segment is still `String`. + assert!(utils_get_type_default(&parse_type("::String")).is_some()); + } + + // ---------- find_function_in_file ---------- + + #[rstest] + #[case("foo", true)] + #[case("bar", true)] + #[case("baz", true)] + #[case("nonexistent", false)] + fn find_function_in_file_cases(#[case] needle: &str, #[case] expected: bool) { + let file: syn::File = syn::parse_str( + r" + fn foo() {} + fn bar() -> i32 { 42 } + fn baz(x: i32) -> i32 { x } + ", + ) + .unwrap(); + assert_eq!(find_function_in_file(&file, needle).is_some(), expected); + } + + // ---------- extract_default_value_from_function ---------- + + #[test] + fn extract_default_value_from_function_direct_expr() { + let func = parse_fn("fn default_value() -> i32 { 42 }"); + assert_eq!( + extract_default_value_from_function(&func), + Some(Value::Number(42.into())) + ); + } + + #[test] + fn extract_default_value_from_function_explicit_return() { + let func = parse_fn(r#"fn default_value() -> String { return "hello".to_string() }"#); + assert_eq!( + extract_default_value_from_function(&func), + Some(Value::String("hello".to_string())) + ); + } + + #[test] + fn extract_default_value_from_function_no_value() { + let func = parse_fn("fn default_value() { let x = 1; }"); + assert!(extract_default_value_from_function(&func).is_none()); + } + + // ---------- extract_schema_default_attr ---------- + + #[rstest] + #[case::with_value( + syn::parse_quote!(#[schema(default = "42")]), + Some("42".to_string()), + )] + #[case::no_default(syn::parse_quote!(#[schema(rename = "foo")]), None)] + #[case::non_schema(syn::parse_quote!(#[serde(default)]), None)] + fn extract_schema_default_attr_cases( + #[case] attr: syn::Attribute, + #[case] expected: Option, + ) { + assert_eq!(extract_schema_default_attr(&[attr]), expected); + } + + // ---------- parse_default_string_to_json_value ---------- + + #[rstest] + #[case::integer("42", json!(42))] + #[case::float("2.72", json!(2.72))] + #[case::bool("true", json!(true))] + #[case::string_fallback("hello world", json!("hello world"))] + fn parse_default_string_to_json_value_cases(#[case] input: &str, #[case] expected: Value) { + assert_eq!(parse_default_string_to_json_value(input), expected); + } + + // ---------- set_property_default ---------- + + fn inline_prop(default: Option) -> SchemaRef { + let mut schema = Schema::object(); + schema.default = default; + SchemaRef::Inline(Box::new(schema)) + } + + fn assert_inline_default( + properties: &BTreeMap, + key: &str, + expected: &Value, + ) { + let SchemaRef::Inline(prop) = properties.get(key).expect("property present") else { + panic!("expected inline schema for {key}"); + }; + assert_eq!(prop.default.as_ref(), Some(expected)); + } + + #[test] + fn set_property_default_sets_value_on_inline_schema_with_no_default() { + let mut properties = BTreeMap::new(); + properties.insert("name".to_string(), inline_prop(None)); + + set_property_default(&mut properties, "name", json!("Alice")); + + assert_inline_default(&properties, "name", &json!("Alice")); + } + + #[test] + fn set_property_default_does_not_overwrite_existing() { + let mut properties = BTreeMap::new(); + properties.insert("name".to_string(), inline_prop(Some(json!("existing")))); + + set_property_default(&mut properties, "name", json!("new")); + + assert_inline_default(&properties, "name", &json!("existing")); + } + + #[test] + fn set_property_default_skips_ref_schema() { + let mut properties = BTreeMap::new(); + properties.insert( + "user".to_string(), + SchemaRef::Ref(Reference::schema("User")), + ); + + set_property_default(&mut properties, "user", json!("ignored")); + + assert!(matches!(properties.get("user"), Some(SchemaRef::Ref(_)))); + } + + #[test] + fn set_property_default_skips_missing_property() { + let mut properties = BTreeMap::new(); + + set_property_default(&mut properties, "nonexistent", json!(42)); + + assert!(properties.is_empty()); + } + + // ---------- process_default_functions ---------- + + #[test] + fn process_default_functions_early_returns_when_properties_none() { + let struct_item: syn::ItemStruct = syn::parse_str("struct Empty;").unwrap(); + let file_ast: syn::File = syn::parse_str("fn foo() {}").unwrap(); + let mut schema = Schema::object(); + schema.properties = None; + + process_default_functions(&struct_item, Some(&file_ast), &mut schema, &BTreeMap::new()); + + assert!(schema.properties.is_none()); + } + + #[test] + fn process_default_functions_applies_schema_default_attr() { + let file_ast: syn::File = syn::parse_str("").unwrap(); + let struct_item: syn::ItemStruct = + syn::parse_str(r#"pub struct Test { #[schema(default = "100")] pub count: i32 }"#) + .unwrap(); + let mut schema = Schema::object(); + let props = schema.properties.get_or_insert_with(BTreeMap::new); + props.insert( + "count".to_string(), + SchemaRef::Inline(Box::new(Schema::integer())), + ); + + process_default_functions(&struct_item, Some(&file_ast), &mut schema, &BTreeMap::new()); + + assert_inline_default(schema.properties.as_ref().unwrap(), "count", &json!(100)); + } +} diff --git a/crates/vespera_macro/src/openapi_generator/paths.rs b/crates/vespera_macro/src/openapi_generator/paths.rs new file mode 100644 index 00000000..aaa19942 --- /dev/null +++ b/crates/vespera_macro/src/openapi_generator/paths.rs @@ -0,0 +1,603 @@ +//! Build `PathItem`s from collected route metadata. +//! +//! This module owns the parallel fan-out infrastructure used during +//! OpenAPI generation: +//! +//! * [`PARALLEL_THRESHOLD`] / [`parallel_filter_map`] — `filter_map` +//! across worker threads, with a sequential fast-path below +//! `PARALLEL_THRESHOLD`. +//! * [`FallbackGuard`] — forces proc-macro2's thread-safe fallback +//! implementation while workers parse `syn` source strings. +//! * [`run_route_jobs_parallel`] — convenience wrapper around +//! `parallel_filter_map` for [`RouteJob`] → [`BuiltOperation`]. +//! +//! Both `build_path_items` (route signatures) and +//! `parse_component_schemas` (struct definitions) drive worker pools +//! through `parallel_filter_map`. + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + +use vespera_core::route::{HttpMethod, PathItem}; + +use crate::{ + metadata::CollectedMetadata, parser::build_operation_from_function, route_impl::StoredRouteInfo, +}; + +/// Build path items and collect tags from route metadata. +/// +/// Uses `route_storage` (from `#[route]` macro) as the primary source for function +/// signatures. Falls back to pre-built `file_cache` when ROUTE_STORAGE doesn't +/// have an entry (e.g., during tests or for routes added without the attribute). +pub(super) fn build_path_items( + metadata: &CollectedMetadata, + known_schema_names: &HashSet, + struct_definitions: &HashMap, + file_cache: &HashMap, + route_storage: &[StoredRouteInfo], +) -> (BTreeMap, BTreeSet) { + let mut paths = BTreeMap::new(); + let mut all_tags = BTreeSet::new(); + + // Build the file-AST function index FIRST so the storage path + // below can skip any function whose AST is already reachable through + // `file_cache`. `collector::collect_metadata` has already walked + // these files via `syn::parse_file`, so re-parsing `fn_item_str` + // from ROUTE_STORAGE for the same function is pure duplicated work. + let fn_index: HashMap<&str, HashMap> = file_cache + .iter() + .map(|(path, ast)| { + let fns: HashMap = ast + .items + .iter() + .filter_map(|item| { + if let syn::Item::Fn(fn_item) = item { + Some((fn_item.sig.ident.to_string(), fn_item)) + } else { + None + } + }) + .collect(); + (path.as_str(), fns) + }) + .collect(); + + // ROUTE_STORAGE-backed function sources (skipped when the same + // function is already covered by `fn_index` — re-parsing would be + // duplicated work). These are plain *strings*, so the expensive + // `syn::parse_str` + operation build runs on worker threads below; + // `syn` ASTs are not `Send`, which is also why fn_index-backed + // routes stay on this thread. + let storage_fn_strs: HashMap<&str, &str> = route_storage + .iter() + .filter_map(|s| { + let already_in_ast = s + .file_path + .as_deref() + .and_then(|fp| fn_index.get(fp)) + .is_some_and(|fns| fns.contains_key(&s.fn_name)); + if already_in_ast { + return None; + } + Some((s.fn_name.as_str(), s.fn_item_str.as_str())) + }) + .collect(); + + // Split routes by signature source. `idx` preserves the original + // route order so PathItem operations are applied deterministically + // regardless of which thread produced them. + let mut parallel_jobs: Vec<(usize, &crate::metadata::RouteMetadata, &str)> = Vec::new(); + let mut ast_jobs: Vec<(usize, &crate::metadata::RouteMetadata, &syn::Signature)> = Vec::new(); + for (idx, route_meta) in metadata.routes.iter().enumerate() { + // ROUTE_STORAGE first (avoids file_cache dependency for known + // routes) — same priority order as the previous sequential code. + if let Some(fn_str) = storage_fn_strs.get(route_meta.function_name.as_str()) { + parallel_jobs.push((idx, route_meta, fn_str)); + } else if let Some(fns) = fn_index.get(route_meta.file_path.as_str()) + && let Some(fn_item) = fns.get(&route_meta.function_name) + { + ast_jobs.push((idx, route_meta, &fn_item.sig)); + } + } + + let build_one = |route_meta: &crate::metadata::RouteMetadata, + fn_sig: &syn::Signature| + -> Option<(HttpMethod, vespera_core::route::Operation)> { + let Ok(method) = HttpMethod::try_from(route_meta.method.as_str()) else { + eprintln!( + "vespera: skipping route '{}' \u{2014} unknown HTTP method '{}'", + route_meta.path, route_meta.method + ); + return None; + }; + let mut operation = build_operation_from_function( + fn_sig, + &route_meta.path, + known_schema_names, + struct_definitions, + route_meta.error_status.as_deref(), + route_meta.tags.as_deref(), + ); + operation.description.clone_from(&route_meta.description); + Some((method, operation)) + }; + + // Parse + build string-backed routes on worker threads. Workers + // produce only `Send` data (`Operation` is plain `vespera_core` + // data); `syn` parsing inside a worker uses proc-macro2's fallback + // implementation, which is thread-safe. + let mut results: Vec<(usize, HttpMethod, vespera_core::route::Operation)> = + run_route_jobs_parallel(¶llel_jobs, &build_one); + + for (idx, route_meta, fn_sig) in ast_jobs { + if let Some((method, operation)) = build_one(route_meta, fn_sig) { + results.push((idx, method, operation)); + } + } + + // Deterministic assembly in original route order. + results.sort_unstable_by_key(|(idx, _, _)| *idx); + for (idx, method, operation) in results { + let route_meta = &metadata.routes[idx]; + if let Some(tags) = &route_meta.tags { + for tag in tags { + all_tags.insert(tag.clone()); + } + } + let path_item = paths + .entry(route_meta.path.clone()) + .or_insert_with(PathItem::default); + path_item.set_operation(method, operation); + } + + (paths, all_tags) +} + +/// Run string-backed route-operation builds across worker threads. +/// +/// Sequential below [`PARALLEL_THRESHOLD`] jobs — thread spawn overhead +/// dominates tiny projects. Chunked `std::thread::scope` otherwise +/// (zero new dependencies). +pub(super) const PARALLEL_THRESHOLD: usize = 16; + +/// `(original route index, route metadata, fn item source)` job input. +pub(super) type RouteJob<'a> = (usize, &'a crate::metadata::RouteMetadata, &'a str); + +/// `(original route index, resolved method, built operation)` result. +pub(super) type BuiltOperation = (usize, HttpMethod, vespera_core::route::Operation); + +/// Builds one operation from a route's resolved fn signature. +pub(super) type OperationBuilder<'a> = dyn Fn( + &crate::metadata::RouteMetadata, + &syn::Signature, + ) -> Option<(HttpMethod, vespera_core::route::Operation)> + + Sync + + 'a; + +/// RAII restore for [`proc_macro2::fallback::force`] — releases the +/// forced fallback mode even when a worker panics. +struct FallbackGuard; + +impl Drop for FallbackGuard { + fn drop(&mut self) { + proc_macro2::fallback::unforce(); + } +} + +fn run_route_jobs_parallel( + jobs: &[RouteJob<'_>], + build_one: &OperationBuilder<'_>, +) -> Vec { + parallel_filter_map(jobs, &|&(idx, route_meta, fn_str): &RouteJob<'_>| { + let fn_item = syn::parse_str::(fn_str).ok()?; + build_one(route_meta, &fn_item.sig).map(|(m, op)| (idx, m, op)) + }) +} + +/// `filter_map` across worker threads for compile-time job fan-out. +/// +/// Sequential below [`PARALLEL_THRESHOLD`] jobs (thread spawn overhead +/// dominates tiny projects); chunked `std::thread::scope` otherwise — +/// zero new dependencies. `f` typically parses source *strings* with +/// `syn` and must return only plain `Send` data: proc-macro2 caches +/// "the compiler bridge works" in a global once it has been used on +/// the macro thread, and worker threads would then take the +/// real-bridge path and panic ("procedural macro API is used outside +/// of a procedural macro") — so the thread-safe fallback +/// implementation is forced for the duration of the parallel section. +/// Workers only ever create fallback tokens, so no compiler/fallback +/// token mixing can occur; the guard restores normal mode even if a +/// worker panics. +pub(super) fn parallel_filter_map( + jobs: &[T], + f: &(dyn Fn(&T) -> Option + Sync), +) -> Vec { + let workers = std::thread::available_parallelism() + .map_or(1, std::num::NonZero::get) + .min(jobs.len().div_ceil(PARALLEL_THRESHOLD)); + if workers <= 1 || jobs.len() < PARALLEL_THRESHOLD { + return jobs.iter().filter_map(f).collect(); + } + + proc_macro2::fallback::force(); + let _guard = FallbackGuard; + + let chunk_size = jobs.len().div_ceil(workers); + std::thread::scope(|scope| { + let handles: Vec<_> = jobs + .chunks(chunk_size) + .map(|chunk| scope.spawn(move || chunk.iter().filter_map(f).collect())) + .collect(); + let mut results: Vec = Vec::with_capacity(jobs.len()); + for handle in handles { + let chunk_results: Vec = handle.join().expect("parallel macro worker panicked"); + results.extend(chunk_results); + } + results + }) +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, fs, path::PathBuf}; + + use rstest::rstest; + use tempfile::TempDir; + + use crate::{ + metadata::{CollectedMetadata, RouteMetadata, StructMetadata}, + openapi_generator::generate_openapi_doc_with_metadata, + route_impl::StoredRouteInfo, + }; + + fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> PathBuf { + let file_path = dir.path().join(filename); + fs::write(&file_path, content).expect("Failed to write temp file"); + file_path + } + + /// Build a `RouteMetadata` with the boilerplate-heavy fields defaulted. + fn route_meta(method: &str, path: &str, fn_name: &str, file_path: &str) -> RouteMetadata { + RouteMetadata { + method: method.to_string(), + path: path.to_string(), + function_name: fn_name.to_string(), + module_path: format!("test::{fn_name}"), + file_path: file_path.to_string(), + error_status: None, + tags: None, + description: None, + } + } + + #[test] + fn route_in_file_cache_appears_in_paths() { + let temp_dir = TempDir::new().unwrap(); + let route_file = create_temp_file( + &temp_dir, + "users.rs", + "pub fn get_users() -> String { \"users\".to_string() }", + ); + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(route_meta( + "GET", + "/users", + "get_users", + &route_file.to_string_lossy(), + )); + + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); + + let op = doc + .paths + .get("/users") + .and_then(|p| p.get.as_ref()) + .expect("GET op"); + assert_eq!(op.operation_id.as_deref(), Some("get_users")); + } + + #[test] + fn route_storage_dedup_skips_already_in_ast() { + // When a route's `fn_item_str` was already discovered by parsing the + // source file via `file_cache`, the storage-parse step must skip + // re-parsing it — exercises the `already_in_ast → return None` + // branch inside `route_fn_cache` construction. + let route_file_path = "/virtual/users.rs".to_string(); + let route_src = "pub fn get_users() -> String { \"users\".to_string() }"; + let parsed: syn::File = syn::parse_str(route_src).expect("route src parses"); + let mut file_cache: HashMap = HashMap::new(); + file_cache.insert(route_file_path.clone(), parsed); + + let mut metadata = CollectedMetadata::new(); + metadata + .routes + .push(route_meta("GET", "/users", "get_users", &route_file_path)); + + let route_storage = vec![StoredRouteInfo { + fn_name: "get_users".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: None, + tags: None, + description: None, + file_path: Some(route_file_path), + fn_item_str: route_src.to_string(), + }]; + + let doc = generate_openapi_doc_with_metadata( + None, + None, + None, + &metadata, + Some(file_cache), + &route_storage, + ); + + let op = doc + .paths + .get("/users") + .and_then(|p| p.get.as_ref()) + .expect("GET op"); + assert_eq!(op.operation_id.as_deref(), Some("get_users")); + } + + #[test] + fn route_storage_fast_path_when_fn_not_in_file_cache() { + let temp_dir = TempDir::new().unwrap(); + let route_file = create_temp_file( + &temp_dir, + "users.rs", + "pub fn get_users() -> String { \"users\".to_string() }\n", + ); + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(route_meta( + "GET", + "/users", + "get_users", + &route_file.to_string_lossy(), + )); + let route_storage = vec![StoredRouteInfo { + fn_name: "get_users".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: None, + tags: None, + description: None, + fn_item_str: "pub fn get_users() -> String { \"users\".to_string() }".to_string(), + file_path: None, + }]; + + let doc = + generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &route_storage); + + let op = doc + .paths + .get("/users") + .and_then(|p| p.get.as_ref()) + .expect("GET op"); + assert_eq!(op.operation_id.as_deref(), Some("get_users")); + } + + #[test] + fn route_with_function_not_in_ast_is_skipped() { + let temp_dir = TempDir::new().unwrap(); + let route_file = create_temp_file( + &temp_dir, + "users.rs", + "pub fn get_items() -> String { \"items\".to_string() }\n", + ); + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(route_meta( + "GET", + "/users", + "get_users", + &route_file.to_string_lossy(), + )); + + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); + + assert!( + doc.paths.is_empty(), + "Route with non-matching function should be skipped" + ); + } + + #[test] + fn route_and_struct_appear_together() { + let temp_dir = TempDir::new().unwrap(); + let route_file = create_temp_file( + &temp_dir, + "user_route.rs", + r#" +use crate::user::User; + +pub fn get_user() -> User { +User { id: 1, name: "Alice".to_string() } +} +"#, + ); + + let mut metadata = CollectedMetadata::new(); + metadata.structs.push(StructMetadata { + name: "User".to_string(), + definition: "struct User { id: i32, name: String }".to_string(), + ..Default::default() + }); + metadata.routes.push(route_meta( + "GET", + "/user", + "get_user", + &route_file.to_string_lossy(), + )); + + let doc = generate_openapi_doc_with_metadata( + Some("Test API".to_string()), + Some("1.0.0".to_string()), + None, + &metadata, + None, + &[], + ); + + let schemas = doc + .components + .as_ref() + .and_then(|c| c.schemas.as_ref()) + .expect("schemas present"); + assert!(schemas.contains_key("User")); + assert!( + doc.paths + .get("/user") + .and_then(|p| p.get.as_ref()) + .is_some() + ); + } + + #[test] + fn multiple_methods_share_path_item() { + let temp_dir = TempDir::new().unwrap(); + let r1 = create_temp_file( + &temp_dir, + "users.rs", + "pub fn get_users() -> String { \"users\".to_string() }", + ); + let r2 = create_temp_file( + &temp_dir, + "create_user.rs", + "pub fn create_user() -> String { \"created\".to_string() }", + ); + + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(route_meta( + "GET", + "/users", + "get_users", + &r1.to_string_lossy(), + )); + metadata.routes.push(route_meta( + "POST", + "/users", + "create_user", + &r2.to_string_lossy(), + )); + + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); + + assert_eq!(doc.paths.len(), 1); + let path_item = doc.paths.get("/users").unwrap(); + assert!(path_item.get.is_some()); + assert!(path_item.post.is_some()); + } + + #[test] + fn tags_and_description_propagate_to_operation() { + let temp_dir = TempDir::new().unwrap(); + let route_file = create_temp_file( + &temp_dir, + "users.rs", + "pub fn get_users() -> String { \"users\".to_string() }", + ); + + let mut metadata = CollectedMetadata::new(); + let mut rm = route_meta("GET", "/users", "get_users", &route_file.to_string_lossy()); + rm.error_status = Some(vec![404]); + rm.tags = Some(vec!["users".to_string(), "admin".to_string()]); + rm.description = Some("Get all users".to_string()); + metadata.routes.push(rm); + + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); + + let op = doc + .paths + .get("/users") + .and_then(|p| p.get.as_ref()) + .unwrap(); + assert_eq!(op.description.as_deref(), Some("Get all users")); + let tags = doc.tags.as_ref().expect("tags present"); + assert!(tags.iter().any(|t| t.name == "users")); + assert!(tags.iter().any(|t| t.name == "admin")); + } + + /// File-read / parse failures must not produce phantom routes or schemas. + #[rstest] + #[case::route_file_read_failure("/nonexistent/route.rs", None)] + #[case::route_file_parse_failure("", Some("invalid rust syntax {"))] + fn file_errors_skip_route( + #[case] file_path_template: &str, + #[case] write_invalid: Option<&str>, + ) { + let temp_dir = TempDir::new().unwrap(); + let final_file_path = write_invalid.map_or_else( + || file_path_template.to_string(), + |content| { + create_temp_file(&temp_dir, "invalid_route.rs", content) + .to_string_lossy() + .to_string() + }, + ); + + let mut metadata = CollectedMetadata::new(); + metadata + .routes + .push(route_meta("GET", "/users", "get_users", &final_file_path)); + + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); + + assert!(!doc.paths.contains_key("/users")); + // schemas must also be empty — no struct was registered. + if let Some(schemas) = doc.components.as_ref().and_then(|c| c.schemas.as_ref()) { + assert!(!schemas.contains_key("User")); + } + } + + #[test] + fn unknown_http_method_route_is_skipped() { + let temp_dir = TempDir::new().unwrap(); + let route_file = create_temp_file( + &temp_dir, + "users.rs", + "pub fn get_users() -> String { \"users\".to_string() }", + ); + + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(route_meta( + "INVALID", + "/users", + "get_users", + &route_file.to_string_lossy(), + )); + + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); + + assert!(doc.paths.is_empty(), "unknown method should be skipped"); + } + + #[test] + fn unknown_method_skipped_valid_kept() { + let temp_dir = TempDir::new().unwrap(); + let route_file = create_temp_file( + &temp_dir, + "users.rs", + r#" +pub fn get_users() -> String { "users".to_string() } + +pub fn create_users() -> String { "created".to_string() } +"#, + ); + let file_path = route_file.to_string_lossy().to_string(); + + let mut metadata = CollectedMetadata::new(); + metadata + .routes + .push(route_meta("CONNECT", "/users", "get_users", &file_path)); + metadata + .routes + .push(route_meta("POST", "/users", "create_users", &file_path)); + + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); + + assert_eq!(doc.paths.len(), 1); + let path_item = doc.paths.get("/users").unwrap(); + assert!(path_item.post.is_some(), "valid POST present"); + assert!(path_item.get.is_none(), "unknown method skipped"); + } +} diff --git a/crates/vespera_macro/src/parser/parameters.rs b/crates/vespera_macro/src/parser/parameters.rs index 551d7832..e8fd4f18 100644 --- a/crates/vespera_macro/src/parser/parameters.rs +++ b/crates/vespera_macro/src/parser/parameters.rs @@ -1,56 +1,14 @@ use std::collections::{HashMap, HashSet}; -use syn::{FnArg, Pat, PatType, Type}; -use vespera_core::{ - route::{Parameter, ParameterLocation}, - schema::{Schema, SchemaRef}, -}; +use syn::{FnArg, Pat, PatType}; +use vespera_core::route::Parameter; -use super::schema::{ - extract_field_rename, extract_rename_all, is_primitive_type, parse_struct_to_schema, - parse_type_to_schema_ref_with_schemas, rename_field, -}; -use crate::schema_macro::type_utils::{ - is_map_type as utils_is_map_type, is_primitive_like as utils_is_primitive_like, -}; +mod header; +mod path; +mod query; +mod shared; -/// Combined check: type is either a JSON-schema primitive or a known container type. -fn is_primitive_or_like(ty: &Type) -> bool { - is_primitive_type(ty) || utils_is_primitive_like(ty) -} - -/// Convert `SchemaRef` for query parameters, adding nullable flag if optional. -/// Preserves `$ref` for known types (e.g. enums) — only wraps with nullable when optional. -fn convert_to_inline_schema(field_schema: SchemaRef, is_optional: bool) -> SchemaRef { - match field_schema { - SchemaRef::Inline(mut schema) => { - if is_optional { - schema.nullable = Some(true); - } - SchemaRef::Inline(schema) - } - SchemaRef::Ref(r) => { - if is_optional { - SchemaRef::Inline(Box::new(Schema { - ref_path: Some(r.ref_path), - schema_type: None, - nullable: Some(true), - ..Default::default() - })) - } else { - SchemaRef::Ref(r) - } - } - } -} - -/// Analyze function parameter and convert to `OpenAPI` Parameter(s) -/// Returns None if parameter should be ignored (e.g., Query<`HashMap`<...>>) -/// Returns Some(Vec) with one or more parameters -/// -/// `path_params` provides ordered access for tuple-index matching in Path handling. -/// `path_param_set` provides O(1) membership test for bare-name path parameter detection. -#[allow(clippy::too_many_lines)] +/// Analyze function parameter and convert to OpenAPI parameter(s). pub fn parse_function_parameter( arg: &FnArg, path_params: &[String], @@ -61,376 +19,49 @@ pub fn parse_function_parameter( match arg { FnArg::Receiver(_) => None, FnArg::Typed(PatType { pat, ty, .. }) => { - // Extract parameter name from pattern - let param_name = match pat.as_ref() { - Pat::Ident(ident) => ident.ident.to_string(), - Pat::TupleStruct(tuple_struct) => { - // Handle Path(id) pattern - if tuple_struct.elems.len() == 1 - && let Pat::Ident(ident) = &tuple_struct.elems[0] - { - ident.ident.to_string() - } else { - return None; - } - } - _ => return None, - }; - - // Check for Option> first - if let Type::Path(type_path) = ty.as_ref() { - let path = &type_path.path; - if !path.segments.is_empty() { - let segment = path.segments.first().unwrap(); - let ident_str = segment.ident.to_string(); - - // Handle Option> - if ident_str == "Option" - && let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() - && let Type::Path(inner_type_path) = inner_ty - && !inner_type_path.path.segments.is_empty() - { - let inner_segment = inner_type_path.path.segments.last().unwrap(); - let inner_ident_str = inner_segment.ident.to_string(); + let param_name = extract_param_name(pat.as_ref())?; - if inner_ident_str == "TypedHeader" { - // TypedHeader always uses string schema regardless of inner type - return Some(vec![Parameter { - name: param_name.replace('_', "-"), - r#in: ParameterLocation::Header, - description: None, - required: Some(false), - schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), - example: None, - }]); - } - } - } + if let Some(parameters) = header::parse_option_typed_header(¶m_name, ty) { + return Some(parameters); } - - // Check for common Axum extractors first (before checking path_params) - // Handle both Path and vespera::axum::extract::Path by checking the last segment - if let Type::Path(type_path) = ty.as_ref() { - let path = &type_path.path; - if !path.segments.is_empty() { - // Check the last segment (handles both Path and vespera::axum::extract::Path) - let segment = path.segments.last().unwrap(); - let ident_str = segment.ident.to_string(); - - match ident_str.as_str() { - "Path" => { - // Path extractor - use path parameter name from route if available - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner_ty)) = - args.args.first() - { - // Check if inner type is a tuple (e.g., Path<(String, String, String)>) - if let Type::Tuple(tuple) = inner_ty { - // For tuple types, extract parameters from path string - let mut parameters = Vec::new(); - let tuple_elems = &tuple.elems; - - // Match tuple elements with path parameters - for (idx, elem_ty) in tuple_elems.iter().enumerate() { - if let Some(param_name) = path_params.get(idx) { - parameters.push(Parameter { - name: param_name.clone(), - r#in: ParameterLocation::Path, - description: None, - required: Some(true), - schema: Some( - parse_type_to_schema_ref_with_schemas( - elem_ty, - known_schemas, - struct_definitions, - ), - ), - example: None, - }); - } - } - - if !parameters.is_empty() { - return Some(parameters); - } - } else { - // Single path parameter - // Allow only when exactly one path parameter is provided - if path_params.len() != 1 { - return None; - } - let name = path_params[0].clone(); - return Some(vec![Parameter { - name, - r#in: ParameterLocation::Path, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - inner_ty, - known_schemas, - struct_definitions, - )), - example: None, - }]); - } - } - } - "Query" => { - // Query extractor - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner_ty)) = - args.args.first() - { - // Check if it's HashMap or BTreeMap - ignore these - if utils_is_map_type(inner_ty) { - return None; - } - - // Check if it's a struct - expand to individual parameters - if let Some(struct_params) = parse_query_struct_to_parameters( - inner_ty, - known_schemas, - struct_definitions, - ) { - return Some(struct_params); - } - - // Ignore primitive-like query params (including Vec/Option of primitive) - if is_primitive_or_like(inner_ty) { - return None; - } - - // Check if it's a known type (primitive or known schema) - // If unknown, don't add parameter - if !is_known_type(inner_ty, known_schemas, struct_definitions) { - return None; - } - - // Otherwise, treat as single parameter - return Some(vec![Parameter { - name: param_name, - r#in: ParameterLocation::Query, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - inner_ty, - known_schemas, - struct_definitions, - )), - example: None, - }]); - } - } - "Header" => { - // Header extractor - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner_ty)) = - args.args.first() - { - // Ignore primitive-like headers - if is_primitive_or_like(inner_ty) { - return None; - } - return Some(vec![Parameter { - name: param_name, - r#in: ParameterLocation::Header, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - inner_ty, - known_schemas, - struct_definitions, - )), - example: None, - }]); - } - } - "TypedHeader" => { - // TypedHeader extractor (axum::TypedHeader) - // TypedHeader always uses string schema regardless of inner type - return Some(vec![Parameter { - name: param_name.replace('_', "-"), - r#in: ParameterLocation::Header, - description: None, - required: Some(true), - schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), - example: None, - }]); - } - "Json" | "Form" | "TypedMultipart" | "Multipart" => { - // These extractors are handled as RequestBody - return None; - } - _ => {} - } - } + if let Some(parameters) = + path::parse_path_extractor(ty, path_params, known_schemas, struct_definitions) + { + return Some(parameters); } - - // Check if it's a path parameter (by name match) - for non-extractor cases - if path_param_set.contains(¶m_name) { - return Some(vec![Parameter { - name: param_name, - r#in: ParameterLocation::Path, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - ty, - known_schemas, - struct_definitions, - )), - example: None, - }]); + if let Some(parameters) = + query::parse_query_extractor(¶m_name, ty, known_schemas, struct_definitions) + { + return Some(parameters); } - - // Bare primitive without extractor is ignored (cannot infer location) - None - } - } -} - -fn is_known_type( - ty: &Type, - known_schemas: &HashSet, - struct_definitions: &HashMap, -) -> bool { - // Check if it's a primitive type - if is_primitive_type(ty) { - return true; - } - - // Check if it's a known struct - if let Type::Path(type_path) = ty { - let path = &type_path.path; - if path.segments.is_empty() { - return false; - } - - let segment = path.segments.last().unwrap(); - let ident_str = segment.ident.to_string(); - - // Get type name (handle both simple and qualified paths) - - // Check if it's in struct_definitions or known_schemas - if struct_definitions.contains_key(&ident_str) || known_schemas.contains(&ident_str) { - return true; - } - - // Check for generic types like Vec, Option - recursively check inner type - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { - match ident_str.as_str() { - "Vec" | "HashSet" | "BTreeSet" | "Option" => { - if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { - return is_known_type(inner_ty, known_schemas, struct_definitions); - } - } - _ => {} + if let Some(parameters) = + header::parse_header_extractor(¶m_name, ty, known_schemas, struct_definitions) + { + return Some(parameters); } + + path::parse_bare_path_parameter( + ¶m_name, + ty, + path_param_set, + known_schemas, + struct_definitions, + ) } } - - false } -/// Parse struct fields to individual query parameters -/// Returns None if the type is not a struct or cannot be parsed -fn parse_query_struct_to_parameters( - ty: &Type, - known_schemas: &HashSet, - struct_definitions: &HashMap, -) -> Option> { - // Check if it's a known struct - if let Type::Path(type_path) = ty { - let path = &type_path.path; - if path.segments.is_empty() { - return None; - } - - let segment = path.segments.last().unwrap(); - let ident_str = segment.ident.to_string(); - - // Get type name (handle both simple and qualified paths) - - // Check if it's a known struct - if let Some(struct_def) = struct_definitions.get(&ident_str) - && let Ok(struct_item) = syn::parse_str::(struct_def) - { - let mut parameters = Vec::new(); - - // Extract rename_all attribute from struct - let rename_all = extract_rename_all(&struct_item.attrs); - - if let syn::Fields::Named(fields_named) = &struct_item.fields { - for field in &fields_named.named { - let rust_field_name = field - .ident - .as_ref() - .map_or_else(|| "unknown".to_string(), std::string::ToString::to_string); - - // Check for field-level rename attribute first (takes precedence) - let field_name = extract_field_rename(&field.attrs) - .unwrap_or_else(|| rename_field(&rust_field_name, rename_all.as_deref())); - - let field_type = &field.ty; - - // Check if field is Option - let is_optional = matches!( - field_type, - Type::Path(type_path) - if type_path - .path - .segments - .first() - .is_some_and(|s| s.ident == "Option") - ); - - // Parse field type to schema (inline, not ref) - // For Query parameters, we need inline schemas, not refs - let mut field_schema = parse_type_to_schema_ref_with_schemas( - field_type, - known_schemas, - struct_definitions, - ); - - // Convert ref to inline if needed (Query parameters should not use refs) - // If it's a ref to a known struct, get the struct definition and inline it - if let SchemaRef::Ref(ref_ref) = &field_schema - && let Some(type_name) = - ref_ref.ref_path.strip_prefix("#/components/schemas/") - && let Some(struct_def) = struct_definitions.get(type_name) - && let Ok(nested_struct_item) = - syn::parse_str::(struct_def) - { - // Parse the nested struct to schema (inline) - let nested_schema = parse_struct_to_schema( - &nested_struct_item, - known_schemas, - struct_definitions, - ); - field_schema = SchemaRef::Inline(Box::new(nested_schema)); - } - - let final_schema = convert_to_inline_schema(field_schema, is_optional); - - let required = !is_optional; - - parameters.push(Parameter { - name: field_name, - r#in: ParameterLocation::Query, - description: None, - required: Some(required), - schema: Some(final_schema), - example: None, - }); - } - } - - if !parameters.is_empty() { - return Some(parameters); - } +fn extract_param_name(pat: &Pat) -> Option { + match pat { + Pat::Ident(ident) => Some(ident.ident.to_string()), + Pat::TupleStruct(tuple_struct) if tuple_struct.elems.len() == 1 => { + let Pat::Ident(ident) = &tuple_struct.elems[0] else { + return None; + }; + Some(ident.ident.to_string()) } + _ => None, } - None } #[cfg(test)] @@ -440,7 +71,6 @@ mod tests { use insta::{assert_debug_snapshot, with_settings}; use rstest::rstest; use vespera_core::route::ParameterLocation; - use vespera_core::schema::{Reference, SchemaType}; use super::*; @@ -452,13 +82,7 @@ mod tests { known_schemas.insert("QueryParams".to_string()); struct_definitions.insert( "QueryParams".to_string(), - r" - pub struct QueryParams { - pub page: i32, - pub limit: Option, - } - " - .to_string(), + r"pub struct QueryParams { pub page: i32, pub limit: Option }".to_string(), ); } @@ -466,13 +90,7 @@ mod tests { known_schemas.insert("User".to_string()); struct_definitions.insert( "User".to_string(), - r" - pub struct User { - pub id: i32, - pub name: String, - } - " - .to_string(), + r"pub struct User { pub id: i32, pub name: String }".to_string(), ); } @@ -480,122 +98,24 @@ mod tests { } #[rstest] - #[case( - "fn test(params: Path<(String, i32)>) {}", - vec!["user_id".to_string(), "count".to_string()], - vec![vec![ParameterLocation::Path, ParameterLocation::Path]], - "path_tuple" - )] - #[case( - "fn show(Path(id): Path) {}", - vec!["item_id".to_string()], - vec![vec![ParameterLocation::Path]], - "path_single" - )] - #[case( - "fn test(Query(params): Query>) {}", - vec![], - vec![vec![]], - "query_hashmap" - )] - #[case( - "fn test(TypedHeader(user_agent): TypedHeader, count: i32) {}", - vec![], - vec![ - vec![ParameterLocation::Header], - vec![], - ], - "typed_header_and_arg" - )] - #[case( - "fn test(TypedHeader(user_agent): TypedHeader, content_type: Option>, authorization: Option>>) {}", - vec![], - vec![ - vec![ParameterLocation::Header], - vec![ParameterLocation::Header], - vec![ParameterLocation::Header], - ], - "typed_header_multi" - )] - #[case( - "fn test(user_agent: TypedHeader, count: i32) {}", - vec![], - vec![ - vec![ParameterLocation::Header], - vec![], - ], - "header_value_and_arg" - )] - #[case( - "fn test(&self, id: i32) {}", - vec![], - vec![ - vec![], - vec![], - ], - "method_receiver" - )] - #[case( - "fn test(Path((a, b)): Path<(i32, String)>) {}", - vec![], - vec![vec![]], - "path_tuple_destructure" - )] - #[case( - "fn test(params: Query) {}", - vec![], - vec![vec![ParameterLocation::Query, ParameterLocation::Query]], - "query_struct" - )] - #[case( - "fn test(body: Json) {}", - vec![], - vec![vec![]], - "json_body" - )] - #[case( - "fn test(params: Query) {}", - vec![], - vec![vec![]], - "query_unknown" - )] - #[case( - "fn test(params: Query>) {}", - vec![], - vec![vec![]], - "query_map" - )] - #[case( - "fn test(user: Query) {}", - vec![], - vec![vec![ParameterLocation::Query, ParameterLocation::Query]], - "query_user" - )] - #[case( - "fn test(custom: Header) {}", - vec![], - vec![vec![ParameterLocation::Header]], - "header_custom" - )] - #[case( - "fn test(input: Form) {}", - vec![], - vec![vec![]], - "form_body" - )] - #[case( - "fn test(upload: TypedMultipart) {}", - vec![], - vec![vec![]], - "typed_multipart_body" - )] - #[case( - "fn test(multipart: Multipart) {}", - vec![], - vec![vec![]], - "raw_multipart_body" - )] - fn test_parse_function_parameter_cases( + #[case("fn test(params: Path<(String, i32)>) {}", vec!["user_id".to_string(), "count".to_string()], vec![vec![ParameterLocation::Path, ParameterLocation::Path]], "path_tuple")] + #[case("fn show(Path(id): Path) {}", vec!["item_id".to_string()], vec![vec![ParameterLocation::Path]], "path_single")] + #[case("fn test(Query(params): Query>) {}", vec![], vec![vec![]], "query_hashmap")] + #[case("fn test(TypedHeader(user_agent): TypedHeader, count: i32) {}", vec![], vec![vec![ParameterLocation::Header], vec![]], "typed_header_and_arg")] + #[case("fn test(TypedHeader(user_agent): TypedHeader, content_type: Option>, authorization: Option>>) {}", vec![], vec![vec![ParameterLocation::Header], vec![ParameterLocation::Header], vec![ParameterLocation::Header]], "typed_header_multi")] + #[case("fn test(user_agent: TypedHeader, count: i32) {}", vec![], vec![vec![ParameterLocation::Header], vec![]], "header_value_and_arg")] + #[case("fn test(&self, id: i32) {}", vec![], vec![vec![], vec![]], "method_receiver")] + #[case("fn test(Path((a, b)): Path<(i32, String)>) {}", vec![], vec![vec![]], "path_tuple_destructure")] + #[case("fn test(params: Query) {}", vec![], vec![vec![ParameterLocation::Query, ParameterLocation::Query]], "query_struct")] + #[case("fn test(body: Json) {}", vec![], vec![vec![]], "json_body")] + #[case("fn test(params: Query) {}", vec![], vec![vec![]], "query_unknown")] + #[case("fn test(params: Query>) {}", vec![], vec![vec![]], "query_map")] + #[case("fn test(user: Query) {}", vec![], vec![vec![ParameterLocation::Query, ParameterLocation::Query]], "query_user")] + #[case("fn test(custom: Header) {}", vec![], vec![vec![ParameterLocation::Header]], "header_custom")] + #[case("fn test(input: Form) {}", vec![], vec![vec![]], "form_body")] + #[case("fn test(upload: TypedMultipart) {}", vec![], vec![vec![]], "typed_multipart_body")] + #[case("fn test(multipart: Multipart) {}", vec![], vec![vec![]], "raw_multipart_body")] + fn parse_function_parameter_cases( #[case] func_src: &str, #[case] path_params: Vec, #[case] expected_locations: Vec>, @@ -634,56 +154,30 @@ mod tests { ); parameters.extend(params.clone()); } - with_settings!({ snapshot_suffix => format!("params_{}", suffix) }, { + with_settings!({ snapshot_path => "snapshots", snapshot_suffix => format!("params_{suffix}") }, { assert_debug_snapshot!(parameters); }); } #[rstest] - #[case( - "fn test(id: Query) {}", - vec![], - )] - #[case( - "fn test(auth: Header) {}", - vec![], - )] - #[case( - "fn test(params: Query>) {}", - vec![], - )] - #[case( - "fn test(params: Query>) {}", - vec![], - )] - #[case( - "fn test(Path([a]): Path<[i32; 1]>) {}", - vec![], - )] - #[case( - "fn test(id: Path) {}", - vec!["user_id".to_string(), "post_id".to_string()], - )] - #[case( - "fn test((x, y): (i32, i32)) {}", - vec![], - )] - fn test_parse_function_parameter_wrong_cases( + #[case("fn test(id: Query) {}", vec![])] + #[case("fn test(auth: Header) {}", vec![])] + #[case("fn test(params: Query>) {}", vec![])] + #[case("fn test(params: Query>) {}", vec![])] + #[case("fn test(Path([a]): Path<[i32; 1]>) {}", vec![])] + #[case("fn test(id: Path) {}", vec!["user_id".to_string(), "post_id".to_string()])] + #[case("fn test((x, y): (i32, i32)) {}", vec![])] + fn parse_function_parameter_wrong_cases( #[case] func_src: &str, #[case] path_params: Vec, ) { let func: syn::ItemFn = syn::parse_str(func_src).unwrap(); - let (known_schemas, struct_definitions) = setup_test_data(func_src); - - // Provide custom types for header/query known schemas/structs - let mut struct_definitions = struct_definitions; + let (mut known_schemas, mut struct_definitions) = setup_test_data(func_src); struct_definitions.insert( "User".to_string(), "pub struct User { pub id: i32 }".to_string(), ); - let mut known_schemas = known_schemas; known_schemas.insert("CustomHeader".to_string()); - let path_param_set: HashSet = path_params.iter().cloned().collect(); for (idx, arg) in func.sig.inputs.iter().enumerate() { @@ -700,585 +194,4 @@ mod tests { ); } } - - #[rstest] - #[case("String", true)] - #[case("i32", true)] - #[case("Vec", true)] - #[case("Option", true)] - #[case("CustomType", false)] - fn test_is_primitive_like_fn(#[case] type_str: &str, #[case] expected: bool) { - let ty: Type = syn::parse_str(type_str).unwrap(); - let result = is_primitive_or_like(&ty); - assert_eq!(result, expected, "type_str={type_str}"); - } - - #[rstest] - #[case("HashMap", true)] - #[case("BTreeMap", true)] - #[case("String", false)] - #[case("Vec", false)] - fn test_is_map_type(#[case] type_str: &str, #[case] expected: bool) { - let ty: Type = syn::parse_str(type_str).unwrap(); - assert_eq!(utils_is_map_type(&ty), expected, "type_str={type_str}"); - } - - #[rstest] - #[case("i32", HashSet::new(), HashMap::new(), true)] // primitive type - #[case( - "User", - HashSet::new(), - { - let mut map = HashMap::new(); - map.insert("User".to_string(), "pub struct User { id: i32 }".to_string()); - map - }, - true - )] // known struct - #[case( - "Product", - { - let mut set = HashSet::new(); - set.insert("Product".to_string()); - set - }, - HashMap::new(), - true - )] // known schema - #[case("Vec", HashSet::new(), HashMap::new(), true)] // Vec with known inner type - #[case("Option", HashSet::new(), HashMap::new(), true)] // Option with known inner type - #[case("UnknownType", HashSet::new(), HashMap::new(), false)] // unknown type - fn test_is_known_type( - #[case] type_str: &str, - #[case] known_schemas: HashSet, - #[case] struct_definitions: HashMap, - #[case] expected: bool, - ) { - let ty: Type = syn::parse_str(type_str).unwrap(); - assert_eq!( - is_known_type(&ty, &known_schemas, &struct_definitions), - expected, - "Type: {type_str}" - ); - } - - #[test] - fn test_parse_query_struct_to_parameters() { - let mut struct_definitions = HashMap::new(); - let mut known_schemas = HashSet::new(); - - // Test with struct that has fields - struct_definitions.insert( - "QueryParams".to_string(), - r#" - #[serde(rename_all = "camelCase")] - pub struct QueryParams { - pub page: i32, - #[serde(rename = "per_page")] - pub limit: Option, - pub search: String, - } - "# - .to_string(), - ); - - let ty: Type = syn::parse_str("QueryParams").unwrap(); - let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); - assert!(result.is_some()); - let params = result.unwrap(); - assert_eq!(params.len(), 3); - assert_eq!(params[0].name, "page"); - assert_eq!(params[0].r#in, ParameterLocation::Query); - assert_eq!(params[1].name, "per_page"); - assert_eq!(params[1].r#in, ParameterLocation::Query); - assert_eq!(params[2].name, "search"); - assert_eq!(params[2].r#in, ParameterLocation::Query); - - // Test with struct that has nested struct (ref to inline conversion) - struct_definitions.insert( - "NestedQuery".to_string(), - r" - pub struct NestedQuery { - pub user: User, - } - " - .to_string(), - ); - struct_definitions.insert( - "User".to_string(), - r" - pub struct User { - pub id: i32, - } - " - .to_string(), - ); - known_schemas.insert("User".to_string()); - - let ty: Type = syn::parse_str("NestedQuery").unwrap(); - let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); - assert!(result.is_some()); - - // Test with non-struct type - let ty: Type = syn::parse_str("i32").unwrap(); - let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); - assert!(result.is_none()); - - // Test with unknown struct - let ty: Type = syn::parse_str("UnknownStruct").unwrap(); - let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); - assert!(result.is_none()); - - // Test with struct that has Option fields - struct_definitions.insert( - "OptionalQuery".to_string(), - r" - pub struct OptionalQuery { - pub required: i32, - pub optional: Option, - } - " - .to_string(), - ); - - let ty: Type = syn::parse_str("OptionalQuery").unwrap(); - let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); - assert!(result.is_some()); - let params = result.unwrap(); - assert_eq!(params.len(), 2); - assert_eq!(params[0].required, Some(true)); - assert_eq!(params[1].required, Some(false)); - } - - // ======== Tests for uncovered lines ======== - - #[test] - fn test_query_single_non_struct_known_type() { - // Test line 128: Return single Query parameter where T is a known non-primitive type - // This should return a single parameter when Query wraps a known type that's not primitive-like - let mut known_schemas = HashSet::new(); - let struct_definitions = HashMap::new(); - - // Add a known type that's not a struct - known_schemas.insert("CustomId".to_string()); - - let func: syn::ItemFn = syn::parse_str("fn test(id: Query) {}").unwrap(); - let path_params: Vec = vec![]; - let path_param_set: HashSet = HashSet::new(); - - for arg in &func.sig.inputs { - let result = parse_function_parameter( - arg, - &path_params, - &path_param_set, - &known_schemas, - &struct_definitions, - ); - // Line 128 returns Some(vec![Parameter...]) for single Query parameter - assert!(result.is_some(), "Expected single Query parameter"); - let params = result.unwrap(); - assert_eq!(params.len(), 1); - assert_eq!(params[0].r#in, ParameterLocation::Query); - } - } - - #[test] - fn test_path_param_by_name_match() { - // Test line 159: path param matched by name (non-extractor case) - // When a parameter name matches a path param name directly without Path extractor - let known_schemas = HashSet::new(); - let struct_definitions = HashMap::new(); - - let func: syn::ItemFn = syn::parse_str("fn test(user_id: i32) {}").unwrap(); - let path_params = vec!["user_id".to_string()]; - let path_param_set: HashSet = path_params.iter().cloned().collect(); - - for arg in &func.sig.inputs { - let result = parse_function_parameter( - arg, - &path_params, - &path_param_set, - &known_schemas, - &struct_definitions, - ); - // Line 159: path_params.contains(¶m_name) returns true, so it creates a Path parameter - assert!(result.is_some(), "Expected path parameter by name match"); - let params = result.unwrap(); - assert_eq!(params.len(), 1); - assert_eq!(params[0].r#in, ParameterLocation::Path); - assert_eq!(params[0].name, "user_id"); - } - } - - #[test] - fn test_is_known_type_empty_segments() { - // Test line 209: empty path segments returns false - // Create a Type::Path programmatically with empty segments - use syn::punctuated::Punctuated; - - let known_schemas = HashSet::new(); - let struct_definitions = HashMap::new(); - - // Create Type::Path with empty segments - let type_path = syn::TypePath { - qself: None, - path: syn::Path { - leading_colon: None, - segments: Punctuated::new(), // Empty segments! - }, - }; - let ty = Type::Path(type_path); - - // Tests: path.segments.is_empty() is true - assert!(!is_known_type(&ty, &known_schemas, &struct_definitions)); - } - - #[test] - fn test_is_known_type_non_vec_option_generic() { - // Test line 230: non-Vec/Option generic type (like Result or Box) - // The match at line 224-229 only handles Vec and Option - let known_schemas = HashSet::new(); - let struct_definitions = HashMap::new(); - - // Box has angle brackets but is not Vec or Option - let ty: Type = syn::parse_str("Box").unwrap(); - // Line 230: the default case `_ => {}` is hit, returns false - assert!(!is_known_type(&ty, &known_schemas, &struct_definitions)); - - // Result also not handled - let ty: Type = syn::parse_str("Result").unwrap(); - assert!(!is_known_type(&ty, &known_schemas, &struct_definitions)); - } - - #[test] - fn test_parse_query_struct_empty_path_segments() { - // Test line 245: empty path segments in parse_query_struct_to_parameters - // Create a Type::Path programmatically with empty segments - use syn::punctuated::Punctuated; - - let known_schemas = HashSet::new(); - let struct_definitions = HashMap::new(); - - // Create Type::Path with empty segments - let type_path = syn::TypePath { - qself: None, - path: syn::Path { - leading_colon: None, - segments: Punctuated::new(), // Empty segments! - }, - }; - let ty = Type::Path(type_path); - - // Tests: path.segments.is_empty() is true - let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); - assert!( - result.is_none(), - "Empty path segments should return None (line 245)" - ); - } - - #[test] - fn test_schema_ref_to_inline_conversion_optional() { - // Test line 313: SchemaRef::Ref converted to inline for Optional fields - // This requires a field that: - // 1. Is Option where T is a known schema - // 2. T is NOT in struct_definitions (so ref stays as Ref) - // 3. field_schema is still Ref after the conversion attempt - // - // Note: parse_type_to_schema_ref_with_schemas for Option may create - // an inline schema wrapping the inner ref, not a direct Ref. - // Line 313 is a defensive case that may be hard to hit in practice. - let mut struct_definitions = HashMap::new(); - let known_schemas = HashSet::new(); - - // Use a simple struct with Option to verify the optional handling works - struct_definitions.insert( - "QueryWithOptional".to_string(), - r" - pub struct QueryWithOptional { - pub count: Option, - } - " - .to_string(), - ); - - let ty: Type = syn::parse_str("QueryWithOptional").unwrap(); - let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); - - assert!(result.is_some()); - let params = result.unwrap(); - assert_eq!(params.len(), 1); - assert_eq!(params[0].required, Some(false)); - match ¶ms[0].schema { - Some(SchemaRef::Inline(schema)) => { - assert_eq!(schema.nullable, Some(true)); - } - _ => panic!("Expected inline schema with nullable"), - } - } - - #[test] - fn test_schema_ref_preserved_for_required_field() { - // Required field with known schema but no struct definition → $ref preserved - let mut struct_definitions = HashMap::new(); - let mut known_schemas = HashSet::new(); - - struct_definitions.insert( - "QueryWithRef".to_string(), - r" - pub struct QueryWithRef { - pub item: RefType, - } - " - .to_string(), - ); - - // RefType is a known schema (will generate SchemaRef::Ref) - // No struct definition, so ref stays as-is (e.g. enum type) - known_schemas.insert("RefType".to_string()); - - let ty: Type = syn::parse_str("QueryWithRef").unwrap(); - let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); - - assert!(result.is_some()); - let params = result.unwrap(); - assert_eq!(params.len(), 1); - // $ref is preserved for required fields - match ¶ms[0].schema { - Some(SchemaRef::Ref(r)) => { - assert_eq!(r.ref_path, "#/components/schemas/RefType"); - } - _ => panic!("Expected $ref schema for required known type"), - } - } - - #[test] - fn test_schema_ref_converted_to_inline_with_struct_def() { - // Test lines 294-304: Ref IS converted when struct_def exists - let mut struct_definitions = HashMap::new(); - let mut known_schemas = HashSet::new(); - - // Main struct with a field of type NestedType - struct_definitions.insert( - "QueryWithNested".to_string(), - r" - pub struct QueryWithNested { - pub nested: NestedType, - } - " - .to_string(), - ); - - // NestedType is both in known_schemas AND has a struct definition - known_schemas.insert("NestedType".to_string()); - struct_definitions.insert( - "NestedType".to_string(), - r" - pub struct NestedType { - pub value: i32, - } - " - .to_string(), - ); - - let ty: Type = syn::parse_str("QueryWithNested").unwrap(); - let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); - - assert!(result.is_some()); - let params = result.unwrap(); - assert_eq!(params.len(), 1); - // Lines 294-304: Ref is converted to inline by parsing the nested struct - match ¶ms[0].schema { - Some(SchemaRef::Inline(_)) => { - // Successfully converted - } - _ => panic!("Expected inline schema (converted from Ref via struct_def)"), - } - } - - // Tests for convert_to_inline_schema helper function - #[test] - fn test_convert_to_inline_schema_inline() { - let schema = SchemaRef::Inline(Box::new(Schema::string())); - let result = convert_to_inline_schema(schema, false); - match result { - SchemaRef::Inline(s) => { - assert_eq!(s.schema_type, Some(SchemaType::String)); - assert!(s.nullable.is_none()); - } - SchemaRef::Ref(_) => panic!("Expected Inline"), - } - } - - #[test] - fn test_convert_to_inline_schema_inline_optional() { - let schema = SchemaRef::Inline(Box::new(Schema::string())); - let result = convert_to_inline_schema(schema, true); - match result { - SchemaRef::Inline(s) => { - assert_eq!(s.schema_type, Some(SchemaType::String)); - assert_eq!(s.nullable, Some(true)); - } - SchemaRef::Ref(_) => panic!("Expected Inline"), - } - } - - #[test] - fn test_convert_to_inline_schema_ref_optional_preserves_ref_path() { - let schema = SchemaRef::Ref(Reference { - ref_path: "#/components/schemas/User".to_string(), - }); - let result = convert_to_inline_schema(schema, true); - match result { - SchemaRef::Inline(s) => { - assert_eq!(s.ref_path, Some("#/components/schemas/User".to_string())); - assert_eq!(s.nullable, Some(true)); - assert_eq!(s.schema_type, None); - } - SchemaRef::Ref(_) => panic!("Expected Inline wrapper for optional $ref"), - } - } - - #[test] - fn test_convert_to_inline_schema_ref_required_passes_through() { - use vespera_core::schema::Reference; - let schema = SchemaRef::Ref(Reference::schema("SomeType")); - let result = convert_to_inline_schema(schema, false); - match result { - SchemaRef::Ref(r) => { - assert_eq!(r.ref_path, "#/components/schemas/SomeType"); - } - SchemaRef::Inline(_) => panic!("Expected $ref pass-through for required field"), - } - } - - #[test] - fn test_convert_to_inline_schema_ref_optional_wraps_nullable() { - use vespera_core::schema::Reference; - let schema = SchemaRef::Ref(Reference::schema("SomeType")); - let result = convert_to_inline_schema(schema, true); - match result { - SchemaRef::Inline(s) => { - assert_eq!( - s.ref_path, - Some("#/components/schemas/SomeType".to_string()) - ); - assert_eq!(s.nullable, Some(true)); - } - SchemaRef::Ref(_) => panic!("Expected Inline wrapper for optional $ref"), - } - } - - // ======== Enum query parameter tests ======== - - #[test] - fn test_query_struct_with_enum_field_produces_ref() { - // Enum field in a query struct should produce $ref to the enum schema - let mut struct_definitions = HashMap::new(); - let mut known_schemas = HashSet::new(); - - struct_definitions.insert( - "FilterParams".to_string(), - r" - pub struct FilterParams { - pub status: Status, - pub page: i32, - } - " - .to_string(), - ); - - // Status is a known enum schema (registered via #[derive(Schema)]) - // Its definition is an enum, so ItemStruct parsing will fail → $ref preserved - known_schemas.insert("Status".to_string()); - struct_definitions.insert( - "Status".to_string(), - r" - pub enum Status { - Active, - Inactive, - Pending, - } - " - .to_string(), - ); - - let ty: Type = syn::parse_str("FilterParams").unwrap(); - let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); - - assert!(result.is_some()); - let params = result.unwrap(); - assert_eq!(params.len(), 2); - - // First param: status → $ref to enum schema - assert_eq!(params[0].name, "status"); - assert_eq!(params[0].r#in, ParameterLocation::Query); - assert_eq!(params[0].required, Some(true)); - match ¶ms[0].schema { - Some(SchemaRef::Ref(r)) => { - assert_eq!(r.ref_path, "#/components/schemas/Status"); - } - _ => panic!( - "Expected $ref for enum query parameter, got: {:?}", - params[0].schema - ), - } - - // Second param: page → inline integer - assert_eq!(params[1].name, "page"); - assert_eq!(params[1].required, Some(true)); - match ¶ms[1].schema { - Some(SchemaRef::Inline(s)) => { - assert_eq!(s.schema_type, Some(SchemaType::Integer)); - } - _ => panic!("Expected inline integer schema"), - } - } - - #[test] - fn test_query_struct_with_optional_enum_field() { - // Option field → nullable $ref - let mut struct_definitions = HashMap::new(); - let mut known_schemas = HashSet::new(); - - struct_definitions.insert( - "FilterParams".to_string(), - r" - pub struct FilterParams { - pub status: Option, - } - " - .to_string(), - ); - - known_schemas.insert("Status".to_string()); - struct_definitions.insert( - "Status".to_string(), - r" - pub enum Status { - Active, - Inactive, - } - " - .to_string(), - ); - - let ty: Type = syn::parse_str("FilterParams").unwrap(); - let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); - - assert!(result.is_some()); - let params = result.unwrap(); - assert_eq!(params.len(), 1); - assert_eq!(params[0].name, "status"); - assert_eq!(params[0].required, Some(false)); - - // Option → inline schema with ref_path + nullable - match ¶ms[0].schema { - Some(SchemaRef::Inline(s)) => { - assert_eq!(s.ref_path, Some("#/components/schemas/Status".to_string())); - assert_eq!(s.nullable, Some(true)); - } - _ => panic!("Expected inline schema with ref_path and nullable for Option"), - } - } } diff --git a/crates/vespera_macro/src/parser/parameters/header.rs b/crates/vespera_macro/src/parser/parameters/header.rs new file mode 100644 index 00000000..96e5e681 --- /dev/null +++ b/crates/vespera_macro/src/parser/parameters/header.rs @@ -0,0 +1,85 @@ +use std::collections::{HashMap, HashSet}; + +use syn::Type; +use vespera_core::{ + route::{Parameter, ParameterLocation}, + schema::{Schema, SchemaRef}, +}; + +use super::shared::is_primitive_or_like; +use crate::parser::schema::parse_type_to_schema_ref_with_schemas; + +pub(super) fn parse_option_typed_header(param_name: &str, ty: &Type) -> Option> { + let Type::Path(type_path) = ty else { + return None; + }; + let segment = type_path.path.segments.first()?; + if segment.ident != "Option" { + return None; + } + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + let Some(syn::GenericArgument::Type(Type::Path(inner_type_path))) = args.args.first() else { + return None; + }; + let inner_segment = inner_type_path.path.segments.last()?; + (inner_segment.ident == "TypedHeader").then(|| vec![typed_header_parameter(param_name, false)]) +} + +pub(super) fn parse_header_extractor( + param_name: &str, + ty: &Type, + known_schemas: &HashSet, + struct_definitions: &HashMap, +) -> Option> { + let Type::Path(type_path) = ty else { + return None; + }; + let segment = type_path.path.segments.last()?; + match segment.ident.to_string().as_str() { + "Header" => parse_header(param_name, segment, known_schemas, struct_definitions), + "TypedHeader" => Some(vec![typed_header_parameter(param_name, true)]), + _ => None, + } +} + +fn parse_header( + param_name: &str, + segment: &syn::PathSegment, + known_schemas: &HashSet, + struct_definitions: &HashMap, +) -> Option> { + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() else { + return None; + }; + if is_primitive_or_like(inner_ty) { + return None; + } + Some(vec![Parameter { + name: param_name.to_string(), + r#in: ParameterLocation::Header, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + inner_ty, + known_schemas, + struct_definitions, + )), + example: None, + }]) +} + +fn typed_header_parameter(param_name: &str, required: bool) -> Parameter { + Parameter { + name: param_name.replace('_', "-"), + r#in: ParameterLocation::Header, + description: None, + required: Some(required), + schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), + example: None, + } +} diff --git a/crates/vespera_macro/src/parser/parameters/path.rs b/crates/vespera_macro/src/parser/parameters/path.rs new file mode 100644 index 00000000..f03a9641 --- /dev/null +++ b/crates/vespera_macro/src/parser/parameters/path.rs @@ -0,0 +1,119 @@ +use std::collections::{HashMap, HashSet}; + +use syn::Type; +use vespera_core::route::{Parameter, ParameterLocation}; + +use crate::parser::schema::parse_type_to_schema_ref_with_schemas; + +pub(super) fn parse_path_extractor( + ty: &Type, + path_params: &[String], + known_schemas: &HashSet, + struct_definitions: &HashMap, +) -> Option> { + let Type::Path(type_path) = ty else { + return None; + }; + let segment = type_path.path.segments.last()?; + if segment.ident != "Path" { + return None; + } + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() else { + return None; + }; + + if let Type::Tuple(tuple) = inner_ty { + let parameters = tuple + .elems + .iter() + .enumerate() + .filter_map(|(idx, elem_ty)| { + path_params.get(idx).map(|param_name| Parameter { + name: param_name.clone(), + r#in: ParameterLocation::Path, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + elem_ty, + known_schemas, + struct_definitions, + )), + example: None, + }) + }) + .collect::>(); + return (!parameters.is_empty()).then_some(parameters); + } + + (path_params.len() == 1).then(|| { + vec![Parameter { + name: path_params[0].clone(), + r#in: ParameterLocation::Path, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + inner_ty, + known_schemas, + struct_definitions, + )), + example: None, + }] + }) +} + +pub(super) fn parse_bare_path_parameter( + param_name: &str, + ty: &Type, + path_param_set: &HashSet, + known_schemas: &HashSet, + struct_definitions: &HashMap, +) -> Option> { + path_param_set.contains(param_name).then(|| { + vec![Parameter { + name: param_name.to_string(), + r#in: ParameterLocation::Path, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + ty, + known_schemas, + struct_definitions, + )), + example: None, + }] + }) +} + +#[cfg(test)] +mod tests { + use std::collections::{HashMap, HashSet}; + + use vespera_core::route::ParameterLocation; + + use crate::parser::parameters::parse_function_parameter; + + #[test] + fn path_param_by_name_match() { + let func: syn::ItemFn = syn::parse_str("fn test(user_id: i32) {}").unwrap(); + let path_params = vec!["user_id".to_string()]; + let path_param_set: HashSet = path_params.iter().cloned().collect(); + + for arg in &func.sig.inputs { + let result = parse_function_parameter( + arg, + &path_params, + &path_param_set, + &HashSet::new(), + &HashMap::new(), + ); + assert!(result.is_some(), "Expected path parameter by name match"); + let params = result.unwrap(); + assert_eq!(params.len(), 1); + assert_eq!(params[0].r#in, ParameterLocation::Path); + assert_eq!(params[0].name, "user_id"); + } + } +} diff --git a/crates/vespera_macro/src/parser/parameters/query.rs b/crates/vespera_macro/src/parser/parameters/query.rs new file mode 100644 index 00000000..f78e6c20 --- /dev/null +++ b/crates/vespera_macro/src/parser/parameters/query.rs @@ -0,0 +1,373 @@ +use std::collections::{HashMap, HashSet}; + +use syn::Type; +use vespera_core::{ + route::{Parameter, ParameterLocation}, + schema::SchemaRef, +}; + +use super::shared::{convert_to_inline_schema, is_known_type, is_primitive_or_like}; +use crate::{ + parser::schema::{ + extract_field_rename, extract_rename_all, parse_struct_to_schema, + parse_type_to_schema_ref_with_schemas, rename_field, + }, + schema_macro::type_utils::is_map_type as utils_is_map_type, +}; + +pub(super) fn parse_query_extractor( + param_name: &str, + ty: &Type, + known_schemas: &HashSet, + struct_definitions: &HashMap, +) -> Option> { + let Type::Path(type_path) = ty else { + return None; + }; + let segment = type_path.path.segments.last()?; + if segment.ident != "Query" { + return None; + } + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() else { + return None; + }; + + if utils_is_map_type(inner_ty) { + return None; + } + if let Some(struct_params) = + parse_query_struct_to_parameters(inner_ty, known_schemas, struct_definitions) + { + return Some(struct_params); + } + if is_primitive_or_like(inner_ty) || !is_known_type(inner_ty, known_schemas, struct_definitions) + { + return None; + } + + Some(vec![Parameter { + name: param_name.to_string(), + r#in: ParameterLocation::Query, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + inner_ty, + known_schemas, + struct_definitions, + )), + example: None, + }]) +} + +pub(super) fn parse_query_struct_to_parameters( + ty: &Type, + known_schemas: &HashSet, + struct_definitions: &HashMap, +) -> Option> { + let Type::Path(type_path) = ty else { + return None; + }; + let path = &type_path.path; + if path.segments.is_empty() { + return None; + } + + let ident_str = path.segments.last().unwrap().ident.to_string(); + if let Some(struct_def) = struct_definitions.get(&ident_str) + && let Ok(struct_item) = syn::parse_str::(struct_def) + { + let mut parameters = Vec::new(); + let rename_all = extract_rename_all(&struct_item.attrs); + + if let syn::Fields::Named(fields_named) = &struct_item.fields { + for field in &fields_named.named { + let rust_field_name = field + .ident + .as_ref() + .map_or_else(|| "unknown".to_string(), std::string::ToString::to_string); + let field_name = extract_field_rename(&field.attrs) + .unwrap_or_else(|| rename_field(&rust_field_name, rename_all.as_deref())); + let field_type = &field.ty; + let is_optional = matches!( + field_type, + Type::Path(type_path) + if type_path + .path + .segments + .first() + .is_some_and(|s| s.ident == "Option") + ); + let mut field_schema = parse_type_to_schema_ref_with_schemas( + field_type, + known_schemas, + struct_definitions, + ); + + if let SchemaRef::Ref(ref_ref) = &field_schema + && let Some(type_name) = ref_ref.ref_path.strip_prefix("#/components/schemas/") + && let Some(struct_def) = struct_definitions.get(type_name) + && let Ok(nested_struct_item) = syn::parse_str::(struct_def) + { + let nested_schema = parse_struct_to_schema( + &nested_struct_item, + known_schemas, + struct_definitions, + ); + field_schema = SchemaRef::Inline(Box::new(nested_schema)); + } + + parameters.push(Parameter { + name: field_name, + r#in: ParameterLocation::Query, + description: None, + required: Some(!is_optional), + schema: Some(convert_to_inline_schema(field_schema, is_optional)), + example: None, + }); + } + } + + if !parameters.is_empty() { + return Some(parameters); + } + } + None +} + +#[cfg(test)] +mod tests { + use std::collections::{HashMap, HashSet}; + + use syn::Type; + use vespera_core::{ + route::ParameterLocation, + schema::{SchemaRef, SchemaType}, + }; + + use super::*; + use crate::parser::parameters::parse_function_parameter; + + #[test] + fn parse_query_struct_to_parameters_cases() { + let mut struct_definitions = HashMap::new(); + let mut known_schemas = HashSet::new(); + + struct_definitions.insert( + "QueryParams".to_string(), + r#"#[serde(rename_all = "camelCase")] + pub struct QueryParams { + pub page: i32, + #[serde(rename = "per_page")] + pub limit: Option, + pub search: String, + }"# + .to_string(), + ); + + let ty: Type = syn::parse_str("QueryParams").unwrap(); + let params = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions) + .expect("query params should parse"); + assert_eq!(params.len(), 3); + assert_eq!(params[0].name, "page"); + assert_eq!(params[0].r#in, ParameterLocation::Query); + assert_eq!(params[1].name, "per_page"); + assert_eq!(params[1].r#in, ParameterLocation::Query); + assert_eq!(params[2].name, "search"); + assert_eq!(params[2].r#in, ParameterLocation::Query); + + struct_definitions.insert( + "NestedQuery".to_string(), + r"pub struct NestedQuery { pub user: User }".to_string(), + ); + struct_definitions.insert( + "User".to_string(), + r"pub struct User { pub id: i32 }".to_string(), + ); + known_schemas.insert("User".to_string()); + + let ty: Type = syn::parse_str("NestedQuery").unwrap(); + assert!( + parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions).is_some() + ); + let ty: Type = syn::parse_str("i32").unwrap(); + assert!( + parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions).is_none() + ); + let ty: Type = syn::parse_str("UnknownStruct").unwrap(); + assert!( + parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions).is_none() + ); + + struct_definitions.insert( + "OptionalQuery".to_string(), + r"pub struct OptionalQuery { pub required: i32, pub optional: Option }" + .to_string(), + ); + let ty: Type = syn::parse_str("OptionalQuery").unwrap(); + let params = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions) + .expect("optional query should parse"); + assert_eq!(params.len(), 2); + assert_eq!(params[0].required, Some(true)); + assert_eq!(params[1].required, Some(false)); + } + + #[test] + fn query_single_non_struct_known_type() { + let mut known_schemas = HashSet::new(); + known_schemas.insert("CustomId".to_string()); + let func: syn::ItemFn = syn::parse_str("fn test(id: Query) {}").unwrap(); + let path_params: Vec = vec![]; + let path_param_set: HashSet = HashSet::new(); + + for arg in &func.sig.inputs { + let result = parse_function_parameter( + arg, + &path_params, + &path_param_set, + &known_schemas, + &HashMap::new(), + ); + assert!(result.is_some(), "Expected single Query parameter"); + let params = result.unwrap(); + assert_eq!(params.len(), 1); + assert_eq!(params[0].r#in, ParameterLocation::Query); + } + } + + #[test] + fn parse_query_struct_empty_path_segments() { + use syn::punctuated::Punctuated; + + let ty = Type::Path(syn::TypePath { + qself: None, + path: syn::Path { + leading_colon: None, + segments: Punctuated::new(), + }, + }); + assert!(parse_query_struct_to_parameters(&ty, &HashSet::new(), &HashMap::new()).is_none()); + } + + #[test] + fn schema_ref_to_inline_conversion_optional() { + let mut struct_definitions = HashMap::new(); + struct_definitions.insert( + "QueryWithOptional".to_string(), + r"pub struct QueryWithOptional { pub count: Option }".to_string(), + ); + + let ty: Type = syn::parse_str("QueryWithOptional").unwrap(); + let params = parse_query_struct_to_parameters(&ty, &HashSet::new(), &struct_definitions) + .expect("query should parse"); + assert_eq!(params.len(), 1); + assert_eq!(params[0].required, Some(false)); + match ¶ms[0].schema { + Some(SchemaRef::Inline(schema)) => assert_eq!(schema.nullable, Some(true)), + _ => panic!("Expected inline schema with nullable"), + } + } + + #[test] + fn schema_ref_preserved_for_required_field() { + let mut struct_definitions = HashMap::new(); + let mut known_schemas = HashSet::new(); + struct_definitions.insert( + "QueryWithRef".to_string(), + r"pub struct QueryWithRef { pub item: RefType }".to_string(), + ); + known_schemas.insert("RefType".to_string()); + + let ty: Type = syn::parse_str("QueryWithRef").unwrap(); + let params = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions) + .expect("query should parse"); + match ¶ms[0].schema { + Some(SchemaRef::Ref(r)) => assert_eq!(r.ref_path, "#/components/schemas/RefType"), + _ => panic!("Expected $ref schema for required known type"), + } + } + + #[test] + fn schema_ref_converted_to_inline_with_struct_def() { + let mut struct_definitions = HashMap::new(); + let mut known_schemas = HashSet::new(); + struct_definitions.insert( + "QueryWithNested".to_string(), + r"pub struct QueryWithNested { pub nested: NestedType }".to_string(), + ); + known_schemas.insert("NestedType".to_string()); + struct_definitions.insert( + "NestedType".to_string(), + r"pub struct NestedType { pub value: i32 }".to_string(), + ); + + let ty: Type = syn::parse_str("QueryWithNested").unwrap(); + let params = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions) + .expect("query should parse"); + assert!(matches!(params[0].schema, Some(SchemaRef::Inline(_)))); + } + + #[test] + fn query_struct_with_enum_field_produces_ref() { + let mut struct_definitions = HashMap::new(); + let mut known_schemas = HashSet::new(); + struct_definitions.insert( + "FilterParams".to_string(), + r"pub struct FilterParams { pub status: Status, pub page: i32 }".to_string(), + ); + known_schemas.insert("Status".to_string()); + struct_definitions.insert( + "Status".to_string(), + r"pub enum Status { Active, Inactive, Pending }".to_string(), + ); + + let ty: Type = syn::parse_str("FilterParams").unwrap(); + let params = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions) + .expect("query should parse"); + assert_eq!(params.len(), 2); + assert_eq!(params[0].name, "status"); + assert_eq!(params[0].r#in, ParameterLocation::Query); + assert_eq!(params[0].required, Some(true)); + match ¶ms[0].schema { + Some(SchemaRef::Ref(r)) => assert_eq!(r.ref_path, "#/components/schemas/Status"), + _ => panic!( + "Expected $ref for enum query parameter, got: {:?}", + params[0].schema + ), + } + assert_eq!(params[1].name, "page"); + match ¶ms[1].schema { + Some(SchemaRef::Inline(s)) => assert_eq!(s.schema_type, Some(SchemaType::Integer)), + _ => panic!("Expected inline integer schema"), + } + } + + #[test] + fn query_struct_with_optional_enum_field() { + let mut struct_definitions = HashMap::new(); + let mut known_schemas = HashSet::new(); + struct_definitions.insert( + "FilterParams".to_string(), + r"pub struct FilterParams { pub status: Option }".to_string(), + ); + known_schemas.insert("Status".to_string()); + struct_definitions.insert( + "Status".to_string(), + r"pub enum Status { Active, Inactive }".to_string(), + ); + + let ty: Type = syn::parse_str("FilterParams").unwrap(); + let params = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions) + .expect("query should parse"); + assert_eq!(params[0].required, Some(false)); + match ¶ms[0].schema { + Some(SchemaRef::Inline(s)) => { + assert_eq!(s.ref_path, Some("#/components/schemas/Status".to_string())); + assert_eq!(s.nullable, Some(true)); + } + _ => panic!("Expected inline schema with ref_path and nullable for Option"), + } + } +} diff --git a/crates/vespera_macro/src/parser/parameters/shared.rs b/crates/vespera_macro/src/parser/parameters/shared.rs new file mode 100644 index 00000000..e7426796 --- /dev/null +++ b/crates/vespera_macro/src/parser/parameters/shared.rs @@ -0,0 +1,210 @@ +use std::collections::{HashMap, HashSet}; + +use syn::Type; +use vespera_core::schema::{Schema, SchemaRef}; + +use crate::{ + parser::schema::is_primitive_type, + schema_macro::type_utils::is_primitive_like as utils_is_primitive_like, +}; + +pub(super) fn is_primitive_or_like(ty: &Type) -> bool { + is_primitive_type(ty) || utils_is_primitive_like(ty) +} + +pub(super) fn convert_to_inline_schema(field_schema: SchemaRef, is_optional: bool) -> SchemaRef { + match field_schema { + SchemaRef::Inline(mut schema) => { + if is_optional { + schema.nullable = Some(true); + } + SchemaRef::Inline(schema) + } + SchemaRef::Ref(r) if is_optional => SchemaRef::Inline(Box::new(Schema { + ref_path: Some(r.ref_path), + schema_type: None, + nullable: Some(true), + ..Default::default() + })), + SchemaRef::Ref(r) => SchemaRef::Ref(r), + } +} + +pub(super) fn is_known_type( + ty: &Type, + known_schemas: &HashSet, + struct_definitions: &HashMap, +) -> bool { + if is_primitive_type(ty) { + return true; + } + + if let Type::Path(type_path) = ty { + let path = &type_path.path; + if path.segments.is_empty() { + return false; + } + + let segment = path.segments.last().unwrap(); + let ident_str = segment.ident.to_string(); + if struct_definitions.contains_key(&ident_str) || known_schemas.contains(&ident_str) { + return true; + } + + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + match ident_str.as_str() { + "Vec" | "HashSet" | "BTreeSet" | "Option" => { + if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { + return is_known_type(inner_ty, known_schemas, struct_definitions); + } + } + _ => {} + } + } + } + + false +} + +#[cfg(test)] +mod tests { + use std::collections::{HashMap, HashSet}; + + use rstest::rstest; + use syn::Type; + use vespera_core::schema::{Reference, Schema, SchemaRef, SchemaType}; + + use super::*; + use crate::schema_macro::type_utils::is_map_type as utils_is_map_type; + + #[rstest] + #[case("String", true)] + #[case("i32", true)] + #[case("Vec", true)] + #[case("Option", true)] + #[case("CustomType", false)] + fn primitive_like(#[case] type_str: &str, #[case] expected: bool) { + let ty: Type = syn::parse_str(type_str).unwrap(); + assert_eq!(is_primitive_or_like(&ty), expected); + } + + #[rstest] + #[case("HashMap", true)] + #[case("BTreeMap", true)] + #[case("String", false)] + #[case("Vec", false)] + fn map_type(#[case] type_str: &str, #[case] expected: bool) { + let ty: Type = syn::parse_str(type_str).unwrap(); + assert_eq!(utils_is_map_type(&ty), expected); + } + + #[rstest] + #[case("i32", HashSet::new(), HashMap::new(), true)] + #[case("User", HashSet::new(), { + let mut map = HashMap::new(); + map.insert("User".to_string(), "pub struct User { id: i32 }".to_string()); + map + }, true)] + #[case("Product", { + let mut set = HashSet::new(); + set.insert("Product".to_string()); + set + }, HashMap::new(), true)] + #[case("Vec", HashSet::new(), HashMap::new(), true)] + #[case("Option", HashSet::new(), HashMap::new(), true)] + #[case("UnknownType", HashSet::new(), HashMap::new(), false)] + fn known_type( + #[case] type_str: &str, + #[case] known_schemas: HashSet, + #[case] struct_definitions: HashMap, + #[case] expected: bool, + ) { + let ty: Type = syn::parse_str(type_str).unwrap(); + assert_eq!( + is_known_type(&ty, &known_schemas, &struct_definitions), + expected + ); + } + + #[test] + fn known_type_empty_segments() { + use syn::punctuated::Punctuated; + + let ty = Type::Path(syn::TypePath { + qself: None, + path: syn::Path { + leading_colon: None, + segments: Punctuated::new(), + }, + }); + assert!(!is_known_type(&ty, &HashSet::new(), &HashMap::new())); + } + + #[test] + fn known_type_non_vec_option_generic() { + let known_schemas = HashSet::new(); + let struct_definitions = HashMap::new(); + let ty: Type = syn::parse_str("Box").unwrap(); + assert!(!is_known_type(&ty, &known_schemas, &struct_definitions)); + let ty: Type = syn::parse_str("Result").unwrap(); + assert!(!is_known_type(&ty, &known_schemas, &struct_definitions)); + } + + #[test] + fn convert_to_inline_schema_inline() { + let schema = SchemaRef::Inline(Box::new(Schema::string())); + let result = convert_to_inline_schema(schema, false); + let SchemaRef::Inline(s) = result else { + panic!("Expected Inline") + }; + assert_eq!(s.schema_type, Some(SchemaType::String)); + assert!(s.nullable.is_none()); + } + + #[test] + fn convert_to_inline_schema_inline_optional() { + let schema = SchemaRef::Inline(Box::new(Schema::string())); + let result = convert_to_inline_schema(schema, true); + let SchemaRef::Inline(s) = result else { + panic!("Expected Inline") + }; + assert_eq!(s.schema_type, Some(SchemaType::String)); + assert_eq!(s.nullable, Some(true)); + } + + #[test] + fn convert_to_inline_schema_ref_optional_preserves_ref_path() { + let schema = SchemaRef::Ref(Reference { + ref_path: "#/components/schemas/User".to_string(), + }); + let result = convert_to_inline_schema(schema, true); + let SchemaRef::Inline(s) = result else { + panic!("Expected Inline wrapper") + }; + assert_eq!(s.ref_path, Some("#/components/schemas/User".to_string())); + assert_eq!(s.nullable, Some(true)); + assert_eq!(s.schema_type, None); + } + + #[test] + fn convert_to_inline_schema_ref_required_passes_through() { + let schema = SchemaRef::Ref(Reference::schema("SomeType")); + let result = convert_to_inline_schema(schema, false); + let SchemaRef::Ref(r) = result else { + panic!("Expected $ref") + }; + assert_eq!(r.ref_path, "#/components/schemas/SomeType"); + } + + #[test] + fn convert_to_inline_schema_ref_optional_wraps_nullable() { + let schema = SchemaRef::Ref(Reference::schema("User")); + let result = convert_to_inline_schema(schema, true); + let SchemaRef::Inline(s) = result else { + panic!("Expected Inline wrapper") + }; + assert_eq!(s.ref_path, Some("#/components/schemas/User".to_string())); + assert_eq!(s.nullable, Some(true)); + assert_eq!(s.schema_type, None); + } +} diff --git a/crates/vespera_macro/src/parser/schema/enum_schema.rs b/crates/vespera_macro/src/parser/schema/enum_schema.rs index c43a9520..17747557 100644 --- a/crates/vespera_macro/src/parser/schema/enum_schema.rs +++ b/crates/vespera_macro/src/parser/schema/enum_schema.rs @@ -1,97 +1,63 @@ -//! Enum to JSON Schema conversion for `OpenAPI` generation. -//! -//! This module handles the conversion of Rust enums (as parsed by syn) -//! into OpenAPI-compatible JSON Schema definitions. -//! -//! ## Supported Serde Enum Representations -//! -//! Vespera supports all four serde enum representations: -//! -//! 1. **Externally Tagged** (default): `{"VariantName": {...}}` -//! 2. **Internally Tagged** (`#[serde(tag = "type")]`): `{"type": "VariantName", ...fields...}` -//! 3. **Adjacently Tagged** (`#[serde(tag = "type", content = "data")]`): `{"type": "VariantName", "data": {...}}` -//! 4. **Untagged** (`#[serde(untagged)]`): `{...fields...}` (no tag) -//! -//! Each representation maps to a different `OpenAPI` schema pattern using `oneOf` and optionally `discriminator`. +//! Enum to JSON Schema conversion for OpenAPI generation. -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{HashMap, HashSet}; -use syn::Type; -use vespera_core::schema::{Discriminator, Schema, SchemaRef, SchemaType}; +use vespera_core::schema::Schema; -use super::{ - serde_attrs::{ - SerdeEnumRepr, extract_doc_comment, extract_enum_repr, extract_field_rename, - extract_rename_all, rename_field, strip_raw_prefix_owned, - }, - type_schema::parse_type_to_schema_ref, +use super::serde_attrs::{ + SerdeEnumRepr, extract_doc_comment, extract_enum_repr, extract_rename_all, }; -/// Parses a Rust enum into an `OpenAPI` Schema. -/// -/// Supports all four serde enum representations: -/// - Externally tagged (default): `{"VariantName": {...}}` -/// - Internally tagged (`#[serde(tag = "type")]`): `{"type": "VariantName", ...fields...}` -/// - Adjacently tagged (`#[serde(tag = "type", content = "data")]`): `{"type": "VariantName", "data": {...}}` -/// - Untagged (`#[serde(untagged)]`): `{...fields...}` (no tag) -/// -/// # Arguments -/// * `enum_item` - The parsed enum from syn -/// * `known_schemas` - Map of known schema names for reference resolution -/// * `struct_definitions` - Map of struct names to their source code (for generics) +mod representations; +mod unit; +mod variant; + +/// Parses a Rust enum into an OpenAPI Schema. pub fn parse_enum_to_schema( enum_item: &syn::ItemEnum, known_schemas: &HashSet, struct_definitions: &HashMap, ) -> Schema { - // Extract enum-level doc comment for schema description let enum_description = extract_doc_comment(&enum_item.attrs); - - // Extract rename_all attribute from enum let rename_all = extract_rename_all(&enum_item.attrs); - - // Detect the serde enum representation let repr = extract_enum_repr(&enum_item.attrs); - - // Check if all variants are unit variants let all_unit = enum_item .variants .iter() .all(|v| matches!(v.fields, syn::Fields::Unit)); - // For simple enums (all unit variants) with externally tagged representation (default), - // they serialize to just the variant name as a string. - // However, internally/adjacently tagged enums serialize unit variants as objects with tag. if all_unit && matches!(repr, SerdeEnumRepr::ExternallyTagged) { - return parse_unit_enum_to_schema(enum_item, enum_description, rename_all.as_deref()); + return unit::parse_unit_enum_to_schema(enum_item, enum_description, rename_all.as_deref()); } match repr { - SerdeEnumRepr::ExternallyTagged => parse_externally_tagged_enum( - enum_item, - enum_description, - rename_all.as_deref(), - known_schemas, - struct_definitions, - ), - SerdeEnumRepr::InternallyTagged { tag } => parse_internally_tagged_enum( + SerdeEnumRepr::ExternallyTagged => representations::parse_externally_tagged_enum( enum_item, enum_description, rename_all.as_deref(), - &tag, known_schemas, struct_definitions, ), - SerdeEnumRepr::AdjacentlyTagged { tag, content } => parse_adjacently_tagged_enum( + SerdeEnumRepr::InternallyTagged { tag } => representations::parse_internally_tagged_enum( enum_item, enum_description, rename_all.as_deref(), &tag, - &content, known_schemas, struct_definitions, ), - SerdeEnumRepr::Untagged => parse_untagged_enum( + SerdeEnumRepr::AdjacentlyTagged { tag, content } => { + representations::parse_adjacently_tagged_enum( + enum_item, + enum_description, + rename_all.as_deref(), + &tag, + &content, + known_schemas, + struct_definitions, + ) + } + SerdeEnumRepr::Untagged => representations::parse_untagged_enum( enum_item, enum_description, rename_all.as_deref(), @@ -101,554 +67,13 @@ pub fn parse_enum_to_schema( } } -/// Parse a simple enum (all unit variants) to a string schema with enum values. -fn parse_unit_enum_to_schema( - enum_item: &syn::ItemEnum, - description: Option, - rename_all: Option<&str>, -) -> Schema { - let mut enum_values = Vec::with_capacity(enum_item.variants.len()); - - for variant in &enum_item.variants { - let variant_name = strip_raw_prefix_owned(variant.ident.to_string()); - - // Check for variant-level rename attribute first (takes precedence) - let enum_value = extract_field_rename(&variant.attrs) - .unwrap_or_else(|| rename_field(&variant_name, rename_all)); - - enum_values.push(serde_json::Value::String(enum_value)); - } - - Schema { - schema_type: Some(SchemaType::String), - description, - r#enum: if enum_values.is_empty() { - None - } else { - Some(enum_values) - }, - ..Schema::string() - } -} - -/// Get the variant key (name after rename transformations) -fn get_variant_key(variant: &syn::Variant, rename_all: Option<&str>) -> String { - let variant_name = strip_raw_prefix_owned(variant.ident.to_string()); - - extract_field_rename(&variant.attrs).unwrap_or_else(|| rename_field(&variant_name, rename_all)) -} - -/// Build properties for a struct variant's fields -fn build_struct_variant_properties( - fields_named: &syn::FieldsNamed, - enum_rename_all: Option<&str>, - variant_attrs: &[syn::Attribute], - known_schemas: &HashSet, - struct_definitions: &HashMap, -) -> (BTreeMap, Vec) { - let mut variant_properties = BTreeMap::new(); - let mut variant_required = Vec::with_capacity(fields_named.named.len()); - let variant_rename_all = extract_rename_all(variant_attrs); - - for field in &fields_named.named { - let rust_field_name = field.ident.as_ref().map_or_else( - || "unknown".to_string(), - |i| strip_raw_prefix_owned(i.to_string()), - ); - - // Check for field-level rename attribute first (takes precedence) - let field_name = extract_field_rename(&field.attrs).unwrap_or_else(|| { - rename_field( - &rust_field_name, - variant_rename_all.as_deref().or(enum_rename_all), - ) - }); - - let field_type = &field.ty; - let mut schema_ref = - parse_type_to_schema_ref(field_type, known_schemas, struct_definitions); - - // Extract doc comment from field and set as description - if let Some(doc) = extract_doc_comment(&field.attrs) { - match &mut schema_ref { - SchemaRef::Inline(schema) => { - schema.description = Some(doc); - } - SchemaRef::Ref(_) => { - let ref_schema = std::mem::replace( - &mut schema_ref, - SchemaRef::Inline(Box::new(Schema::object())), - ); - if let SchemaRef::Ref(reference) = ref_schema { - schema_ref = SchemaRef::Inline(Box::new(Schema { - description: Some(doc), - all_of: Some(vec![SchemaRef::Ref(reference)]), - ..Default::default() - })); - } - } - } - } - - variant_properties.insert(field_name.clone(), schema_ref); - - // Check if field is Option - let is_optional = matches!( - field_type, - Type::Path(type_path) - if type_path - .path - .segments - .first() - .is_some_and(|s| s.ident == "Option") - ); - - if !is_optional { - variant_required.push(field_name); - } - } - - (variant_properties, variant_required) -} - -/// Build a schema for a variant's data (tuple or struct fields) -fn build_variant_data_schema( - variant: &syn::Variant, - enum_rename_all: Option<&str>, - known_schemas: &HashSet, - struct_definitions: &HashMap, -) -> Option { - match &variant.fields { - syn::Fields::Unit => None, - syn::Fields::Unnamed(fields_unnamed) => { - if fields_unnamed.unnamed.len() == 1 { - // Single field tuple variant - just the inner type - let inner_type = &fields_unnamed.unnamed[0].ty; - Some(parse_type_to_schema_ref( - inner_type, - known_schemas, - struct_definitions, - )) - } else { - // Multiple fields tuple variant - array with prefixItems - let mut tuple_item_schemas = Vec::with_capacity(fields_unnamed.unnamed.len()); - for field in &fields_unnamed.unnamed { - let field_schema = - parse_type_to_schema_ref(&field.ty, known_schemas, struct_definitions); - tuple_item_schemas.push(field_schema); - } - - let tuple_len = tuple_item_schemas.len(); - Some(SchemaRef::Inline(Box::new(Schema { - prefix_items: Some(tuple_item_schemas), - min_items: Some(tuple_len), - max_items: Some(tuple_len), - items: None, - ..Schema::new(SchemaType::Array) - }))) - } - } - syn::Fields::Named(fields_named) => { - let (properties, required) = build_struct_variant_properties( - fields_named, - enum_rename_all, - &variant.attrs, - known_schemas, - struct_definitions, - ); - - Some(SchemaRef::Inline(Box::new(Schema { - properties: if properties.is_empty() { - None - } else { - Some(properties) - }, - required: if required.is_empty() { - None - } else { - Some(required) - }, - ..Schema::object() - }))) - } - } -} - -/// Parse externally tagged enum: `{"VariantName": {...}}` -/// This is serde's default representation. -fn parse_externally_tagged_enum( - enum_item: &syn::ItemEnum, - description: Option, - rename_all: Option<&str>, - known_schemas: &HashSet, - struct_definitions: &HashMap, -) -> Schema { - let mut one_of_schemas = Vec::with_capacity(enum_item.variants.len()); - - for variant in &enum_item.variants { - let variant_key = get_variant_key(variant, rename_all); - let variant_description = extract_doc_comment(&variant.attrs); - - let variant_schema = match &variant.fields { - syn::Fields::Unit => { - // Unit variant in mixed enum: string with const value - Schema { - description: variant_description, - r#enum: Some(vec![serde_json::Value::String(variant_key)]), - ..Schema::string() - } - } - syn::Fields::Unnamed(fields_unnamed) => { - // Tuple variant: {"VariantName": } - let data_schema = if fields_unnamed.unnamed.len() == 1 { - let inner_type = &fields_unnamed.unnamed[0].ty; - parse_type_to_schema_ref(inner_type, known_schemas, struct_definitions) - } else { - // Multiple fields - array with prefixItems - let mut tuple_item_schemas = Vec::with_capacity(fields_unnamed.unnamed.len()); - for field in &fields_unnamed.unnamed { - let field_schema = - parse_type_to_schema_ref(&field.ty, known_schemas, struct_definitions); - tuple_item_schemas.push(field_schema); - } - let tuple_len = tuple_item_schemas.len(); - SchemaRef::Inline(Box::new(Schema { - prefix_items: Some(tuple_item_schemas), - min_items: Some(tuple_len), - max_items: Some(tuple_len), - items: None, - ..Schema::new(SchemaType::Array) - })) - }; - - let mut properties = BTreeMap::new(); - properties.insert(variant_key.clone(), data_schema); - - Schema { - description: variant_description, - properties: Some(properties), - required: Some(vec![variant_key]), - ..Schema::object() - } - } - syn::Fields::Named(fields_named) => { - // Struct variant: {"VariantName": {field1: type1, ...}} - let (inner_properties, inner_required) = build_struct_variant_properties( - fields_named, - rename_all, - &variant.attrs, - known_schemas, - struct_definitions, - ); - - let inner_struct_schema = Schema { - properties: if inner_properties.is_empty() { - None - } else { - Some(inner_properties) - }, - required: if inner_required.is_empty() { - None - } else { - Some(inner_required) - }, - ..Schema::object() - }; - - let mut properties = BTreeMap::new(); - properties.insert( - variant_key.clone(), - SchemaRef::Inline(Box::new(inner_struct_schema)), - ); - - Schema { - description: variant_description, - properties: Some(properties), - required: Some(vec![variant_key]), - ..Schema::object() - } - } - }; - - one_of_schemas.push(SchemaRef::Inline(Box::new(variant_schema))); - } - - Schema { - schema_type: None, - description, - one_of: if one_of_schemas.is_empty() { - None - } else { - Some(one_of_schemas) - }, - ..Schema::new(SchemaType::Object) - } -} - -/// Parse internally tagged enum: `{"tag": "VariantName", ...fields...}` -/// Uses `OpenAPI` discriminator for the tag field. -/// Note: serde only allows struct and unit variants for internally tagged enums. -fn parse_internally_tagged_enum( - enum_item: &syn::ItemEnum, - description: Option, - rename_all: Option<&str>, - tag: &str, - known_schemas: &HashSet, - struct_definitions: &HashMap, -) -> Schema { - let mut one_of_schemas = Vec::with_capacity(enum_item.variants.len()); - - let tag_string = tag.to_string(); - - for variant in &enum_item.variants { - let variant_key = get_variant_key(variant, rename_all); - let variant_description = extract_doc_comment(&variant.attrs); - - let variant_schema = match &variant.fields { - syn::Fields::Unit => { - // Unit variant: {"tag": "VariantName"} - let mut properties = BTreeMap::new(); - properties.insert( - tag_string.clone(), - SchemaRef::Inline(Box::new(Schema { - r#enum: Some(vec![serde_json::Value::String(variant_key.clone())]), - ..Schema::string() - })), - ); - - Schema { - description: variant_description, - properties: Some(properties), - required: Some(vec![tag_string.clone()]), - ..Schema::object() - } - } - syn::Fields::Named(fields_named) => { - // Struct variant: {"tag": "VariantName", field1: type1, ...} - let (mut properties, mut required) = build_struct_variant_properties( - fields_named, - rename_all, - &variant.attrs, - known_schemas, - struct_definitions, - ); - - // Add the tag field - properties.insert( - tag_string.clone(), - SchemaRef::Inline(Box::new(Schema { - r#enum: Some(vec![serde_json::Value::String(variant_key.clone())]), - ..Schema::string() - })), - ); - required.insert(0, tag_string.clone()); - - Schema { - description: variant_description, - properties: Some(properties), - required: Some(required), - ..Schema::object() - } - } - syn::Fields::Unnamed(_) => { - // Tuple/newtype variants are not supported with internally tagged enums in serde - // Generate a warning schema or skip - continue; - } - }; - - one_of_schemas.push(SchemaRef::Inline(Box::new(variant_schema))); - } - - Schema { - schema_type: None, - description, - one_of: if one_of_schemas.is_empty() { - None - } else { - Some(one_of_schemas) - }, - discriminator: Some(Discriminator { - property_name: tag_string, - mapping: None, // Mapping not needed for inline schemas - }), - ..Default::default() - } -} - -/// Parse adjacently tagged enum: `{"tag": "VariantName", "content": {...}}` -/// Uses `OpenAPI` discriminator for the tag field. -fn parse_adjacently_tagged_enum( - enum_item: &syn::ItemEnum, - description: Option, - rename_all: Option<&str>, - tag: &str, - content: &str, - known_schemas: &HashSet, - struct_definitions: &HashMap, -) -> Schema { - let mut one_of_schemas = Vec::with_capacity(enum_item.variants.len()); - - let tag_string = tag.to_string(); - let content_string = content.to_string(); - - for variant in &enum_item.variants { - let variant_key = get_variant_key(variant, rename_all); - let variant_description = extract_doc_comment(&variant.attrs); - - let mut properties = BTreeMap::new(); - let mut required = vec![tag_string.clone()]; - - // Add the tag field - properties.insert( - tag_string.clone(), - SchemaRef::Inline(Box::new(Schema { - r#enum: Some(vec![serde_json::Value::String(variant_key.clone())]), - ..Schema::string() - })), - ); - - // Add the content field if variant has data - if let Some(data_schema) = - build_variant_data_schema(variant, rename_all, known_schemas, struct_definitions) - { - properties.insert(content_string.clone(), data_schema); - required.push(content_string.clone()); - } - - let variant_schema = Schema { - description: variant_description, - properties: Some(properties), - required: Some(required), - ..Schema::object() - }; - - one_of_schemas.push(SchemaRef::Inline(Box::new(variant_schema))); - } - - Schema { - schema_type: None, - description, - one_of: if one_of_schemas.is_empty() { - None - } else { - Some(one_of_schemas) - }, - discriminator: Some(Discriminator { - property_name: tag_string, - mapping: None, - }), - ..Default::default() - } -} - -/// Parse untagged enum: variant data only, no tag. -/// Uses oneOf without discriminator - validation relies on schema structure matching. -fn parse_untagged_enum( - enum_item: &syn::ItemEnum, - description: Option, - rename_all: Option<&str>, - known_schemas: &HashSet, - struct_definitions: &HashMap, -) -> Schema { - let mut one_of_schemas = Vec::with_capacity(enum_item.variants.len()); - - for variant in &enum_item.variants { - let variant_description = extract_doc_comment(&variant.attrs); - - let variant_schema = match &variant.fields { - syn::Fields::Unit => { - // Unit variant in untagged enum: null - Schema { - description: variant_description, - schema_type: Some(SchemaType::Null), - ..Default::default() - } - } - syn::Fields::Unnamed(fields_unnamed) => { - if fields_unnamed.unnamed.len() == 1 { - // Single field tuple variant - just the inner type - let inner_type = &fields_unnamed.unnamed[0].ty; - let mut schema = match parse_type_to_schema_ref( - inner_type, - known_schemas, - struct_definitions, - ) { - SchemaRef::Inline(s) => *s, - SchemaRef::Ref(r) => Schema { - all_of: Some(vec![SchemaRef::Ref(r)]), - ..Default::default() - }, - }; - schema.description = variant_description.or(schema.description); - schema - } else { - // Multiple fields - array with prefixItems - let mut tuple_item_schemas = Vec::with_capacity(fields_unnamed.unnamed.len()); - for field in &fields_unnamed.unnamed { - let field_schema = - parse_type_to_schema_ref(&field.ty, known_schemas, struct_definitions); - tuple_item_schemas.push(field_schema); - } - let tuple_len = tuple_item_schemas.len(); - Schema { - description: variant_description, - prefix_items: Some(tuple_item_schemas), - min_items: Some(tuple_len), - max_items: Some(tuple_len), - items: None, - ..Schema::new(SchemaType::Array) - } - } - } - syn::Fields::Named(fields_named) => { - // Struct variant - just the object with fields - let (properties, required) = build_struct_variant_properties( - fields_named, - rename_all, - &variant.attrs, - known_schemas, - struct_definitions, - ); - - Schema { - description: variant_description, - properties: if properties.is_empty() { - None - } else { - Some(properties) - }, - required: if required.is_empty() { - None - } else { - Some(required) - }, - ..Schema::object() - } - } - }; - - one_of_schemas.push(SchemaRef::Inline(Box::new(variant_schema))); - } - - Schema { - schema_type: None, - description, - one_of: if one_of_schemas.is_empty() { - None - } else { - Some(one_of_schemas) - }, - ..Default::default() - } -} - #[cfg(test)] mod tests { use insta::{assert_debug_snapshot, with_settings}; use rstest::rstest; use super::*; + use vespera_core::schema::{SchemaRef, SchemaType}; #[rstest] #[case( @@ -704,7 +129,7 @@ mod tests { .map(|v| v.as_str().unwrap().to_string()) .collect::>(); assert_eq!(got, expected_enum); - with_settings!({ snapshot_suffix => format!("unit_{}", suffix) }, { + with_settings!({ snapshot_path => "snapshots", snapshot_suffix => format!("unit_{}", suffix) }, { assert_debug_snapshot!(schema); }); } @@ -798,7 +223,7 @@ mod tests { } } - with_settings!({ snapshot_suffix => format!("tuple_named_{}", suffix) }, { + with_settings!({ snapshot_path => "snapshots", snapshot_suffix => format!("tuple_named_{}", suffix) }, { assert_debug_snapshot!(schema); }); } @@ -1181,557 +606,4 @@ mod tests { SchemaRef::Ref(_) => panic!("Expected inline schema with allOf, not direct $ref"), } } - - // Tests for serde enum representation support - mod enum_repr_tests { - use super::*; - - // Internally tagged enum tests - #[test] - fn test_internally_tagged_enum_unit_variants() { - let enum_item: syn::ItemEnum = syn::parse_str( - r#" - #[serde(tag = "type")] - enum Message { - Ping, - Pong, - } - "#, - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - - // Should have discriminator - let discriminator = schema - .discriminator - .as_ref() - .expect("discriminator missing"); - assert_eq!(discriminator.property_name, "type"); - - // Should have oneOf - let one_of = schema.one_of.expect("one_of missing"); - assert_eq!(one_of.len(), 2); - - // Each variant should be an object with "type" property - if let SchemaRef::Inline(ping) = &one_of[0] { - let props = ping.properties.as_ref().expect("properties missing"); - assert!(props.contains_key("type")); - let required = ping.required.as_ref().expect("required missing"); - assert!(required.contains(&"type".to_string())); - } else { - panic!("Expected inline schema"); - } - } - - #[test] - fn test_internally_tagged_enum_struct_variants() { - let enum_item: syn::ItemEnum = syn::parse_str( - r#" - #[serde(tag = "kind")] - enum Event { - Created { id: i32, name: String }, - Updated { id: i32 }, - } - "#, - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - - // Should have discriminator with custom tag name - let discriminator = schema - .discriminator - .as_ref() - .expect("discriminator missing"); - assert_eq!(discriminator.property_name, "kind"); - - let one_of = schema.one_of.expect("one_of missing"); - assert_eq!(one_of.len(), 2); - - // Created variant should have kind, id, and name - if let SchemaRef::Inline(created) = &one_of[0] { - let props = created.properties.as_ref().expect("properties missing"); - assert!(props.contains_key("kind")); - assert!(props.contains_key("id")); - assert!(props.contains_key("name")); - } else { - panic!("Expected inline schema"); - } - } - - #[test] - fn test_internally_tagged_enum_with_rename_all() { - let enum_item: syn::ItemEnum = syn::parse_str( - r#" - #[serde(tag = "type", rename_all = "snake_case")] - enum Status { - ActiveUser, - InactiveUser, - } - "#, - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - - let one_of = schema.one_of.expect("one_of missing"); - if let SchemaRef::Inline(active) = &one_of[0] { - let props = active.properties.as_ref().expect("properties missing"); - if let SchemaRef::Inline(type_schema) = props.get("type").expect("type missing") { - let enum_vals = type_schema.r#enum.as_ref().expect("enum values missing"); - assert_eq!(enum_vals[0].as_str().unwrap(), "active_user"); - } - } - } - - // Adjacently tagged enum tests - #[test] - fn test_adjacently_tagged_enum_basic() { - let enum_item: syn::ItemEnum = syn::parse_str( - r#" - #[serde(tag = "type", content = "data")] - enum Response { - Success { result: String }, - Error { message: String }, - } - "#, - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - - // Should have discriminator - let discriminator = schema - .discriminator - .as_ref() - .expect("discriminator missing"); - assert_eq!(discriminator.property_name, "type"); - - let one_of = schema.one_of.expect("one_of missing"); - assert_eq!(one_of.len(), 2); - - // Each variant should have "type" and "data" properties - if let SchemaRef::Inline(success) = &one_of[0] { - let props = success.properties.as_ref().expect("properties missing"); - assert!(props.contains_key("type")); - assert!(props.contains_key("data")); - - let required = success.required.as_ref().expect("required missing"); - assert!(required.contains(&"type".to_string())); - assert!(required.contains(&"data".to_string())); - } else { - panic!("Expected inline schema"); - } - } - - #[test] - fn test_adjacently_tagged_enum_with_unit_variant() { - let enum_item: syn::ItemEnum = syn::parse_str( - r#" - #[serde(tag = "type", content = "payload")] - enum Command { - Ping, - Message { text: String }, - } - "#, - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - - let one_of = schema.one_of.expect("one_of missing"); - assert_eq!(one_of.len(), 2); - - // Ping (unit variant) should only have "type", no "payload" - if let SchemaRef::Inline(ping) = &one_of[0] { - let props = ping.properties.as_ref().expect("properties missing"); - assert!(props.contains_key("type")); - assert!(!props.contains_key("payload")); // Unit variant has no content - - let required = ping.required.as_ref().expect("required missing"); - assert_eq!(required.len(), 1); // Only "type" is required - assert!(required.contains(&"type".to_string())); - } - - // Message should have both "type" and "payload" - if let SchemaRef::Inline(message) = &one_of[1] { - let props = message.properties.as_ref().expect("properties missing"); - assert!(props.contains_key("type")); - assert!(props.contains_key("payload")); - } - } - - #[test] - fn test_adjacently_tagged_enum_tuple_variant() { - let enum_item: syn::ItemEnum = syn::parse_str( - r#" - #[serde(tag = "t", content = "c")] - enum Value { - Int(i32), - Pair(i32, String), - } - "#, - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - - let one_of = schema.one_of.expect("one_of missing"); - assert_eq!(one_of.len(), 2); - - // Int variant - content should be integer schema - if let SchemaRef::Inline(int_variant) = &one_of[0] { - let props = int_variant.properties.as_ref().expect("properties missing"); - let content = props.get("c").expect("content missing"); - if let SchemaRef::Inline(content_schema) = content { - assert_eq!(content_schema.schema_type, Some(SchemaType::Integer)); - } - } - - // Pair variant - content should be array with prefixItems - if let SchemaRef::Inline(pair_variant) = &one_of[1] { - let props = pair_variant - .properties - .as_ref() - .expect("properties missing"); - let content = props.get("c").expect("content missing"); - if let SchemaRef::Inline(content_schema) = content { - assert_eq!(content_schema.schema_type, Some(SchemaType::Array)); - assert!(content_schema.prefix_items.is_some()); - } - } - } - - // Untagged enum tests - #[test] - fn test_untagged_enum_basic() { - let enum_item: syn::ItemEnum = syn::parse_str( - r" - #[serde(untagged)] - enum StringOrInt { - String(String), - Int(i32), - } - ", - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - - // Should NOT have discriminator - assert!(schema.discriminator.is_none()); - - let one_of = schema.one_of.expect("one_of missing"); - assert_eq!(one_of.len(), 2); - - // First variant should be string schema directly (not wrapped in object) - if let SchemaRef::Inline(string_variant) = &one_of[0] { - assert_eq!(string_variant.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline schema"); - } - - // Second variant should be integer schema directly - if let SchemaRef::Inline(int_variant) = &one_of[1] { - assert_eq!(int_variant.schema_type, Some(SchemaType::Integer)); - } else { - panic!("Expected inline schema"); - } - } - - #[test] - fn test_untagged_enum_struct_variants() { - let enum_item: syn::ItemEnum = syn::parse_str( - r" - #[serde(untagged)] - enum Data { - User { name: String, age: i32 }, - Product { title: String, price: f64 }, - } - ", - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - - assert!(schema.discriminator.is_none()); - - let one_of = schema.one_of.expect("one_of missing"); - assert_eq!(one_of.len(), 2); - - // User variant should be object with name and age (no wrapper) - if let SchemaRef::Inline(user) = &one_of[0] { - assert_eq!(user.schema_type, Some(SchemaType::Object)); - let props = user.properties.as_ref().expect("properties missing"); - assert!(props.contains_key("name")); - assert!(props.contains_key("age")); - } - } - - #[test] - fn test_untagged_enum_unit_variant() { - let enum_item: syn::ItemEnum = syn::parse_str( - r" - #[serde(untagged)] - enum MaybeValue { - Nothing, - Something(i32), - } - ", - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - - let one_of = schema.one_of.expect("one_of missing"); - assert_eq!(one_of.len(), 2); - - // Unit variant in untagged enum should be null - if let SchemaRef::Inline(nothing) = &one_of[0] { - assert_eq!(nothing.schema_type, Some(SchemaType::Null)); - } - } - - // Snapshot tests for new representations - #[test] - fn test_internally_tagged_snapshot() { - let enum_item: syn::ItemEnum = syn::parse_str( - r#" - #[serde(tag = "type")] - enum Message { - Request { id: i32, method: String }, - Response { id: i32, result: Option }, - Notification, - } - "#, - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - with_settings!({ snapshot_suffix => "internally_tagged" }, { - assert_debug_snapshot!(schema); - }); - } - - #[test] - fn test_adjacently_tagged_snapshot() { - let enum_item: syn::ItemEnum = syn::parse_str( - r#" - #[serde(tag = "type", content = "data")] - enum ApiResponse { - Success { items: Vec }, - Error { code: i32, message: String }, - Empty, - } - "#, - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - with_settings!({ snapshot_suffix => "adjacently_tagged" }, { - assert_debug_snapshot!(schema); - }); - } - - #[test] - fn test_untagged_snapshot() { - let enum_item: syn::ItemEnum = syn::parse_str( - r" - #[serde(untagged)] - enum Value { - Null, - Bool(bool), - Number(f64), - Text(String), - Object { key: String, value: String }, - } - ", - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - with_settings!({ snapshot_suffix => "untagged" }, { - assert_debug_snapshot!(schema); - }); - } - - // Edge case: Empty struct variant (empty properties/required) - #[test] - fn test_externally_tagged_empty_struct_variant() { - let enum_item: syn::ItemEnum = syn::parse_str( - r" - enum Event { - /// Empty struct variant - Empty {}, - Data { value: i32 }, - } - ", - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - - let one_of = schema.clone().one_of.expect("one_of missing"); - assert_eq!(one_of.len(), 2); - - // Empty variant should have properties with Empty key pointing to object with no properties - if let SchemaRef::Inline(empty_variant) = &one_of[0] { - let props = empty_variant - .properties - .as_ref() - .expect("variant props missing"); - let SchemaRef::Inline(inner) = props.get("Empty").expect("Empty key missing") - else { - panic!("Expected inline schema") - }; - // Empty struct should have properties: None and required: None - assert!(inner.properties.is_none()); - assert!(inner.required.is_none()); - } - - with_settings!({ snapshot_suffix => "externally_tagged_empty_struct" }, { - assert_debug_snapshot!(schema); - }); - } - - // Edge case: Internally tagged enum with tuple variant - #[test] - fn test_internally_tagged_skips_tuple_variant() { - let enum_item: syn::ItemEnum = syn::parse_str( - r#" - #[serde(tag = "type")] - enum Message { - Text { content: String }, - Number(i32), - Empty, - } - "#, - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - - // Tuple variant `Number(i32)` should be skipped, only 2 variants should remain - let one_of = schema.clone().one_of.expect("one_of missing"); - assert_eq!(one_of.len(), 2); // Text and Empty only - - // Verify discriminator is present - let discriminator = schema - .discriminator - .as_ref() - .expect("discriminator missing"); - assert_eq!(discriminator.property_name, "type"); - - with_settings!({ snapshot_suffix => "internally_tagged_skip_tuple" }, { - assert_debug_snapshot!(schema); - }); - } - - // Edge case: Untagged enum with tuple variant referencing a known schema - #[test] - fn test_untagged_tuple_variant_with_known_schema_ref() { - let enum_item: syn::ItemEnum = syn::parse_str( - r" - #[serde(untagged)] - enum Payload { - User(UserData), - Simple(String), - } - ", - ) - .unwrap(); - - // Provide UserData as a known schema so it returns SchemaRef::Ref - let mut known_schemas = HashSet::new(); - known_schemas.insert("UserData".to_string()); - - let schema = parse_enum_to_schema(&enum_item, &known_schemas, &HashMap::new()); - - assert!(schema.discriminator.is_none()); - - let one_of = schema.one_of.expect("one_of missing"); - assert_eq!(one_of.len(), 2); - - // First variant (UserData) should have all_of with a $ref since it's a known schema - if let SchemaRef::Inline(user_variant) = &one_of[0] { - // The schema should have all_of containing the reference - let all_of = user_variant - .all_of - .as_ref() - .expect("all_of missing for known schema ref"); - assert_eq!(all_of.len(), 1); - if let SchemaRef::Ref(reference) = &all_of[0] { - assert!(reference.ref_path.contains("UserData")); - } else { - panic!("Expected SchemaRef::Ref inside all_of"); - } - } else { - panic!("Expected inline schema"); - } - - // Second variant (String) should be inline string schema directly - if let SchemaRef::Inline(simple_variant) = &one_of[1] { - assert_eq!(simple_variant.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline schema"); - } - } - - // Edge case: Untagged enum with multi-field tuple variant - #[test] - fn test_untagged_multi_field_tuple_variant() { - let enum_item: syn::ItemEnum = syn::parse_str( - r" - #[serde(untagged)] - enum Message { - Text(String), - Pair(i32, String), - Triple(i32, String, bool), - } - ", - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - - assert!(schema.discriminator.is_none()); - - let one_of = schema.clone().one_of.expect("one_of missing"); - assert_eq!(one_of.len(), 3); - - // Single-field tuple should be string schema directly - if let SchemaRef::Inline(text_variant) = &one_of[0] { - assert_eq!(text_variant.schema_type, Some(SchemaType::String)); - } - - // Multi-field tuple (Pair) should be array with prefixItems - if let SchemaRef::Inline(pair_variant) = &one_of[1] { - assert_eq!(pair_variant.schema_type, Some(SchemaType::Array)); - let prefix_items = pair_variant - .prefix_items - .as_ref() - .expect("prefix_items missing for Pair"); - assert_eq!(prefix_items.len(), 2); - assert_eq!(pair_variant.min_items, Some(2)); - assert_eq!(pair_variant.max_items, Some(2)); - } - - // Multi-field tuple (Triple) should be array with 3 prefixItems - if let SchemaRef::Inline(triple_variant) = &one_of[2] { - assert_eq!(triple_variant.schema_type, Some(SchemaType::Array)); - let prefix_items = triple_variant - .prefix_items - .as_ref() - .expect("prefix_items missing for Triple"); - assert_eq!(prefix_items.len(), 3); - assert_eq!(triple_variant.min_items, Some(3)); - assert_eq!(triple_variant.max_items, Some(3)); - } - - with_settings!({ snapshot_suffix => "untagged_multi_field_tuple" }, { - assert_debug_snapshot!(schema); - }); - } - } } diff --git a/crates/vespera_macro/src/parser/schema/enum_schema/representations.rs b/crates/vespera_macro/src/parser/schema/enum_schema/representations.rs new file mode 100644 index 00000000..a7083e02 --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/enum_schema/representations.rs @@ -0,0 +1,934 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; + +use vespera_core::schema::{Discriminator, Schema, SchemaRef, SchemaType}; + +use super::super::{serde_attrs::extract_doc_comment, type_schema::parse_type_to_schema_ref}; +use super::{ + unit::get_variant_key, + variant::{build_struct_variant_properties, build_variant_data_schema}, +}; + +/// Parse externally tagged enum: `{"VariantName": {...}}` +/// This is serde's default representation. +pub(super) fn parse_externally_tagged_enum( + enum_item: &syn::ItemEnum, + description: Option, + rename_all: Option<&str>, + known_schemas: &HashSet, + struct_definitions: &HashMap, +) -> Schema { + let mut one_of_schemas = Vec::with_capacity(enum_item.variants.len()); + + for variant in &enum_item.variants { + let variant_key = get_variant_key(variant, rename_all); + let variant_description = extract_doc_comment(&variant.attrs); + + let variant_schema = match &variant.fields { + syn::Fields::Unit => { + // Unit variant in mixed enum: string with const value + Schema { + description: variant_description, + r#enum: Some(vec![serde_json::Value::String(variant_key)]), + ..Schema::string() + } + } + syn::Fields::Unnamed(fields_unnamed) => { + // Tuple variant: {"VariantName": } + let data_schema = if fields_unnamed.unnamed.len() == 1 { + let inner_type = &fields_unnamed.unnamed[0].ty; + parse_type_to_schema_ref(inner_type, known_schemas, struct_definitions) + } else { + // Multiple fields - array with prefixItems + let mut tuple_item_schemas = Vec::with_capacity(fields_unnamed.unnamed.len()); + for field in &fields_unnamed.unnamed { + let field_schema = + parse_type_to_schema_ref(&field.ty, known_schemas, struct_definitions); + tuple_item_schemas.push(field_schema); + } + let tuple_len = tuple_item_schemas.len(); + SchemaRef::Inline(Box::new(Schema { + prefix_items: Some(tuple_item_schemas), + min_items: Some(tuple_len), + max_items: Some(tuple_len), + items: None, + ..Schema::new(SchemaType::Array) + })) + }; + + let mut properties = BTreeMap::new(); + properties.insert(variant_key.clone(), data_schema); + + Schema { + description: variant_description, + properties: Some(properties), + required: Some(vec![variant_key]), + ..Schema::object() + } + } + syn::Fields::Named(fields_named) => { + // Struct variant: {"VariantName": {field1: type1, ...}} + let (inner_properties, inner_required) = build_struct_variant_properties( + fields_named, + rename_all, + &variant.attrs, + known_schemas, + struct_definitions, + ); + + let inner_struct_schema = Schema { + properties: if inner_properties.is_empty() { + None + } else { + Some(inner_properties) + }, + required: if inner_required.is_empty() { + None + } else { + Some(inner_required) + }, + ..Schema::object() + }; + + let mut properties = BTreeMap::new(); + properties.insert( + variant_key.clone(), + SchemaRef::Inline(Box::new(inner_struct_schema)), + ); + + Schema { + description: variant_description, + properties: Some(properties), + required: Some(vec![variant_key]), + ..Schema::object() + } + } + }; + + one_of_schemas.push(SchemaRef::Inline(Box::new(variant_schema))); + } + + Schema { + schema_type: None, + description, + one_of: if one_of_schemas.is_empty() { + None + } else { + Some(one_of_schemas) + }, + ..Schema::new(SchemaType::Object) + } +} + +/// Parse internally tagged enum: `{"tag": "VariantName", ...fields...}` +/// Uses `OpenAPI` discriminator for the tag field. +/// Note: serde only allows struct and unit variants for internally tagged enums. +pub(super) fn parse_internally_tagged_enum( + enum_item: &syn::ItemEnum, + description: Option, + rename_all: Option<&str>, + tag: &str, + known_schemas: &HashSet, + struct_definitions: &HashMap, +) -> Schema { + let mut one_of_schemas = Vec::with_capacity(enum_item.variants.len()); + + let tag_string = tag.to_string(); + + for variant in &enum_item.variants { + let variant_key = get_variant_key(variant, rename_all); + let variant_description = extract_doc_comment(&variant.attrs); + + let variant_schema = match &variant.fields { + syn::Fields::Unit => { + // Unit variant: {"tag": "VariantName"} + let mut properties = BTreeMap::new(); + properties.insert( + tag_string.clone(), + SchemaRef::Inline(Box::new(Schema { + r#enum: Some(vec![serde_json::Value::String(variant_key.clone())]), + ..Schema::string() + })), + ); + + Schema { + description: variant_description, + properties: Some(properties), + required: Some(vec![tag_string.clone()]), + ..Schema::object() + } + } + syn::Fields::Named(fields_named) => { + // Struct variant: {"tag": "VariantName", field1: type1, ...} + let (mut properties, mut required) = build_struct_variant_properties( + fields_named, + rename_all, + &variant.attrs, + known_schemas, + struct_definitions, + ); + + // Add the tag field + properties.insert( + tag_string.clone(), + SchemaRef::Inline(Box::new(Schema { + r#enum: Some(vec![serde_json::Value::String(variant_key.clone())]), + ..Schema::string() + })), + ); + required.insert(0, tag_string.clone()); + + Schema { + description: variant_description, + properties: Some(properties), + required: Some(required), + ..Schema::object() + } + } + syn::Fields::Unnamed(_) => { + // Tuple/newtype variants are not supported with internally tagged enums in serde + // Generate a warning schema or skip + continue; + } + }; + + one_of_schemas.push(SchemaRef::Inline(Box::new(variant_schema))); + } + + Schema { + schema_type: None, + description, + one_of: if one_of_schemas.is_empty() { + None + } else { + Some(one_of_schemas) + }, + discriminator: Some(Discriminator { + property_name: tag_string, + mapping: None, // Mapping not needed for inline schemas + }), + ..Default::default() + } +} + +/// Parse adjacently tagged enum: `{"tag": "VariantName", "content": {...}}` +/// Uses `OpenAPI` discriminator for the tag field. +pub(super) fn parse_adjacently_tagged_enum( + enum_item: &syn::ItemEnum, + description: Option, + rename_all: Option<&str>, + tag: &str, + content: &str, + known_schemas: &HashSet, + struct_definitions: &HashMap, +) -> Schema { + let mut one_of_schemas = Vec::with_capacity(enum_item.variants.len()); + + let tag_string = tag.to_string(); + let content_string = content.to_string(); + + for variant in &enum_item.variants { + let variant_key = get_variant_key(variant, rename_all); + let variant_description = extract_doc_comment(&variant.attrs); + + let mut properties = BTreeMap::new(); + let mut required = vec![tag_string.clone()]; + + // Add the tag field + properties.insert( + tag_string.clone(), + SchemaRef::Inline(Box::new(Schema { + r#enum: Some(vec![serde_json::Value::String(variant_key.clone())]), + ..Schema::string() + })), + ); + + // Add the content field if variant has data + if let Some(data_schema) = + build_variant_data_schema(variant, rename_all, known_schemas, struct_definitions) + { + properties.insert(content_string.clone(), data_schema); + required.push(content_string.clone()); + } + + let variant_schema = Schema { + description: variant_description, + properties: Some(properties), + required: Some(required), + ..Schema::object() + }; + + one_of_schemas.push(SchemaRef::Inline(Box::new(variant_schema))); + } + + Schema { + schema_type: None, + description, + one_of: if one_of_schemas.is_empty() { + None + } else { + Some(one_of_schemas) + }, + discriminator: Some(Discriminator { + property_name: tag_string, + mapping: None, + }), + ..Default::default() + } +} + +/// Parse untagged enum: variant data only, no tag. +/// Uses oneOf without discriminator - validation relies on schema structure matching. +pub(super) fn parse_untagged_enum( + enum_item: &syn::ItemEnum, + description: Option, + rename_all: Option<&str>, + known_schemas: &HashSet, + struct_definitions: &HashMap, +) -> Schema { + let mut one_of_schemas = Vec::with_capacity(enum_item.variants.len()); + + for variant in &enum_item.variants { + let variant_description = extract_doc_comment(&variant.attrs); + + let variant_schema = match &variant.fields { + syn::Fields::Unit => { + // Unit variant in untagged enum: null + Schema { + description: variant_description, + schema_type: Some(SchemaType::Null), + ..Default::default() + } + } + syn::Fields::Unnamed(fields_unnamed) => { + if fields_unnamed.unnamed.len() == 1 { + // Single field tuple variant - just the inner type + let inner_type = &fields_unnamed.unnamed[0].ty; + let mut schema = match parse_type_to_schema_ref( + inner_type, + known_schemas, + struct_definitions, + ) { + SchemaRef::Inline(s) => *s, + SchemaRef::Ref(r) => Schema { + all_of: Some(vec![SchemaRef::Ref(r)]), + ..Default::default() + }, + }; + schema.description = variant_description.or(schema.description); + schema + } else { + // Multiple fields - array with prefixItems + let mut tuple_item_schemas = Vec::with_capacity(fields_unnamed.unnamed.len()); + for field in &fields_unnamed.unnamed { + let field_schema = + parse_type_to_schema_ref(&field.ty, known_schemas, struct_definitions); + tuple_item_schemas.push(field_schema); + } + let tuple_len = tuple_item_schemas.len(); + Schema { + description: variant_description, + prefix_items: Some(tuple_item_schemas), + min_items: Some(tuple_len), + max_items: Some(tuple_len), + items: None, + ..Schema::new(SchemaType::Array) + } + } + } + syn::Fields::Named(fields_named) => { + // Struct variant - just the object with fields + let (properties, required) = build_struct_variant_properties( + fields_named, + rename_all, + &variant.attrs, + known_schemas, + struct_definitions, + ); + + Schema { + description: variant_description, + properties: if properties.is_empty() { + None + } else { + Some(properties) + }, + required: if required.is_empty() { + None + } else { + Some(required) + }, + ..Schema::object() + } + } + }; + + one_of_schemas.push(SchemaRef::Inline(Box::new(variant_schema))); + } + + Schema { + schema_type: None, + description, + one_of: if one_of_schemas.is_empty() { + None + } else { + Some(one_of_schemas) + }, + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use std::collections::{HashMap, HashSet}; + + use crate::parser::schema::enum_schema::parse_enum_to_schema; + use insta::{assert_debug_snapshot, with_settings}; + use vespera_core::schema::{SchemaRef, SchemaType}; + + // Internally tagged enum tests + #[test] + fn test_internally_tagged_enum_unit_variants() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(tag = "type")] + enum Message { + Ping, + Pong, + } + "#, + ) + .unwrap(); + + let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + + // Should have discriminator + let discriminator = schema + .discriminator + .as_ref() + .expect("discriminator missing"); + assert_eq!(discriminator.property_name, "type"); + + // Should have oneOf + let one_of = schema.one_of.expect("one_of missing"); + assert_eq!(one_of.len(), 2); + + // Each variant should be an object with "type" property + if let SchemaRef::Inline(ping) = &one_of[0] { + let props = ping.properties.as_ref().expect("properties missing"); + assert!(props.contains_key("type")); + let required = ping.required.as_ref().expect("required missing"); + assert!(required.contains(&"type".to_string())); + } else { + panic!("Expected inline schema"); + } + } + + #[test] + fn test_internally_tagged_enum_struct_variants() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(tag = "kind")] + enum Event { + Created { id: i32, name: String }, + Updated { id: i32 }, + } + "#, + ) + .unwrap(); + + let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + + // Should have discriminator with custom tag name + let discriminator = schema + .discriminator + .as_ref() + .expect("discriminator missing"); + assert_eq!(discriminator.property_name, "kind"); + + let one_of = schema.one_of.expect("one_of missing"); + assert_eq!(one_of.len(), 2); + + // Created variant should have kind, id, and name + if let SchemaRef::Inline(created) = &one_of[0] { + let props = created.properties.as_ref().expect("properties missing"); + assert!(props.contains_key("kind")); + assert!(props.contains_key("id")); + assert!(props.contains_key("name")); + } else { + panic!("Expected inline schema"); + } + } + + #[test] + fn test_internally_tagged_enum_with_rename_all() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(tag = "type", rename_all = "snake_case")] + enum Status { + ActiveUser, + InactiveUser, + } + "#, + ) + .unwrap(); + + let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + + let one_of = schema.one_of.expect("one_of missing"); + if let SchemaRef::Inline(active) = &one_of[0] { + let props = active.properties.as_ref().expect("properties missing"); + if let SchemaRef::Inline(type_schema) = props.get("type").expect("type missing") { + let enum_vals = type_schema.r#enum.as_ref().expect("enum values missing"); + assert_eq!(enum_vals[0].as_str().unwrap(), "active_user"); + } + } + } + + // Adjacently tagged enum tests + #[test] + fn test_adjacently_tagged_enum_basic() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(tag = "type", content = "data")] + enum Response { + Success { result: String }, + Error { message: String }, + } + "#, + ) + .unwrap(); + + let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + + // Should have discriminator + let discriminator = schema + .discriminator + .as_ref() + .expect("discriminator missing"); + assert_eq!(discriminator.property_name, "type"); + + let one_of = schema.one_of.expect("one_of missing"); + assert_eq!(one_of.len(), 2); + + // Each variant should have "type" and "data" properties + if let SchemaRef::Inline(success) = &one_of[0] { + let props = success.properties.as_ref().expect("properties missing"); + assert!(props.contains_key("type")); + assert!(props.contains_key("data")); + + let required = success.required.as_ref().expect("required missing"); + assert!(required.contains(&"type".to_string())); + assert!(required.contains(&"data".to_string())); + } else { + panic!("Expected inline schema"); + } + } + + #[test] + fn test_adjacently_tagged_enum_with_unit_variant() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(tag = "type", content = "payload")] + enum Command { + Ping, + Message { text: String }, + } + "#, + ) + .unwrap(); + + let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + + let one_of = schema.one_of.expect("one_of missing"); + assert_eq!(one_of.len(), 2); + + // Ping (unit variant) should only have "type", no "payload" + if let SchemaRef::Inline(ping) = &one_of[0] { + let props = ping.properties.as_ref().expect("properties missing"); + assert!(props.contains_key("type")); + assert!(!props.contains_key("payload")); // Unit variant has no content + + let required = ping.required.as_ref().expect("required missing"); + assert_eq!(required.len(), 1); // Only "type" is required + assert!(required.contains(&"type".to_string())); + } + + // Message should have both "type" and "payload" + if let SchemaRef::Inline(message) = &one_of[1] { + let props = message.properties.as_ref().expect("properties missing"); + assert!(props.contains_key("type")); + assert!(props.contains_key("payload")); + } + } + + #[test] + fn test_adjacently_tagged_enum_tuple_variant() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(tag = "t", content = "c")] + enum Value { + Int(i32), + Pair(i32, String), + } + "#, + ) + .unwrap(); + + let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + + let one_of = schema.one_of.expect("one_of missing"); + assert_eq!(one_of.len(), 2); + + // Int variant - content should be integer schema + if let SchemaRef::Inline(int_variant) = &one_of[0] { + let props = int_variant.properties.as_ref().expect("properties missing"); + let content = props.get("c").expect("content missing"); + if let SchemaRef::Inline(content_schema) = content { + assert_eq!(content_schema.schema_type, Some(SchemaType::Integer)); + } + } + + // Pair variant - content should be array with prefixItems + if let SchemaRef::Inline(pair_variant) = &one_of[1] { + let props = pair_variant + .properties + .as_ref() + .expect("properties missing"); + let content = props.get("c").expect("content missing"); + if let SchemaRef::Inline(content_schema) = content { + assert_eq!(content_schema.schema_type, Some(SchemaType::Array)); + assert!(content_schema.prefix_items.is_some()); + } + } + } + + // Untagged enum tests + #[test] + fn test_untagged_enum_basic() { + let enum_item: syn::ItemEnum = syn::parse_str( + r" + #[serde(untagged)] + enum StringOrInt { + String(String), + Int(i32), + } + ", + ) + .unwrap(); + + let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + + // Should NOT have discriminator + assert!(schema.discriminator.is_none()); + + let one_of = schema.one_of.expect("one_of missing"); + assert_eq!(one_of.len(), 2); + + // First variant should be string schema directly (not wrapped in object) + if let SchemaRef::Inline(string_variant) = &one_of[0] { + assert_eq!(string_variant.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected inline schema"); + } + + // Second variant should be integer schema directly + if let SchemaRef::Inline(int_variant) = &one_of[1] { + assert_eq!(int_variant.schema_type, Some(SchemaType::Integer)); + } else { + panic!("Expected inline schema"); + } + } + + #[test] + fn test_untagged_enum_struct_variants() { + let enum_item: syn::ItemEnum = syn::parse_str( + r" + #[serde(untagged)] + enum Data { + User { name: String, age: i32 }, + Product { title: String, price: f64 }, + } + ", + ) + .unwrap(); + + let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + + assert!(schema.discriminator.is_none()); + + let one_of = schema.one_of.expect("one_of missing"); + assert_eq!(one_of.len(), 2); + + // User variant should be object with name and age (no wrapper) + if let SchemaRef::Inline(user) = &one_of[0] { + assert_eq!(user.schema_type, Some(SchemaType::Object)); + let props = user.properties.as_ref().expect("properties missing"); + assert!(props.contains_key("name")); + assert!(props.contains_key("age")); + } + } + + #[test] + fn test_untagged_enum_unit_variant() { + let enum_item: syn::ItemEnum = syn::parse_str( + r" + #[serde(untagged)] + enum MaybeValue { + Nothing, + Something(i32), + } + ", + ) + .unwrap(); + + let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + + let one_of = schema.one_of.expect("one_of missing"); + assert_eq!(one_of.len(), 2); + + // Unit variant in untagged enum should be null + if let SchemaRef::Inline(nothing) = &one_of[0] { + assert_eq!(nothing.schema_type, Some(SchemaType::Null)); + } + } + + // Snapshot tests for new representations + #[test] + fn test_internally_tagged_snapshot() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(tag = "type")] + enum Message { + Request { id: i32, method: String }, + Response { id: i32, result: Option }, + Notification, + } + "#, + ) + .unwrap(); + + let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => "internally_tagged" }, { + assert_debug_snapshot!(schema); + }); + } + + #[test] + fn test_adjacently_tagged_snapshot() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(tag = "type", content = "data")] + enum ApiResponse { + Success { items: Vec }, + Error { code: i32, message: String }, + Empty, + } + "#, + ) + .unwrap(); + + let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => "adjacently_tagged" }, { + assert_debug_snapshot!(schema); + }); + } + + #[test] + fn test_untagged_snapshot() { + let enum_item: syn::ItemEnum = syn::parse_str( + r" + #[serde(untagged)] + enum Value { + Null, + Bool(bool), + Number(f64), + Text(String), + Object { key: String, value: String }, + } + ", + ) + .unwrap(); + + let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => "untagged" }, { + assert_debug_snapshot!(schema); + }); + } + + // Edge case: Empty struct variant (empty properties/required) + #[test] + fn test_externally_tagged_empty_struct_variant() { + let enum_item: syn::ItemEnum = syn::parse_str( + r" + enum Event { + /// Empty struct variant + Empty {}, + Data { value: i32 }, + } + ", + ) + .unwrap(); + + let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + + let one_of = schema.clone().one_of.expect("one_of missing"); + assert_eq!(one_of.len(), 2); + + // Empty variant should have properties with Empty key pointing to object with no properties + if let SchemaRef::Inline(empty_variant) = &one_of[0] { + let props = empty_variant + .properties + .as_ref() + .expect("variant props missing"); + let SchemaRef::Inline(inner) = props.get("Empty").expect("Empty key missing") else { + panic!("Expected inline schema") + }; + // Empty struct should have properties: None and required: None + assert!(inner.properties.is_none()); + assert!(inner.required.is_none()); + } + + with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => "externally_tagged_empty_struct" }, { + assert_debug_snapshot!(schema); + }); + } + + // Edge case: Internally tagged enum with tuple variant + #[test] + fn test_internally_tagged_skips_tuple_variant() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(tag = "type")] + enum Message { + Text { content: String }, + Number(i32), + Empty, + } + "#, + ) + .unwrap(); + + let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + + // Tuple variant `Number(i32)` should be skipped, only 2 variants should remain + let one_of = schema.clone().one_of.expect("one_of missing"); + assert_eq!(one_of.len(), 2); // Text and Empty only + + // Verify discriminator is present + let discriminator = schema + .discriminator + .as_ref() + .expect("discriminator missing"); + assert_eq!(discriminator.property_name, "type"); + + with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => "internally_tagged_skip_tuple" }, { + assert_debug_snapshot!(schema); + }); + } + + // Edge case: Untagged enum with tuple variant referencing a known schema + #[test] + fn test_untagged_tuple_variant_with_known_schema_ref() { + let enum_item: syn::ItemEnum = syn::parse_str( + r" + #[serde(untagged)] + enum Payload { + User(UserData), + Simple(String), + } + ", + ) + .unwrap(); + + // Provide UserData as a known schema so it returns SchemaRef::Ref + let mut known_schemas = HashSet::new(); + known_schemas.insert("UserData".to_string()); + + let schema = parse_enum_to_schema(&enum_item, &known_schemas, &HashMap::new()); + + assert!(schema.discriminator.is_none()); + + let one_of = schema.one_of.expect("one_of missing"); + assert_eq!(one_of.len(), 2); + + // First variant (UserData) should have all_of with a $ref since it's a known schema + if let SchemaRef::Inline(user_variant) = &one_of[0] { + // The schema should have all_of containing the reference + let all_of = user_variant + .all_of + .as_ref() + .expect("all_of missing for known schema ref"); + assert_eq!(all_of.len(), 1); + if let SchemaRef::Ref(reference) = &all_of[0] { + assert!(reference.ref_path.contains("UserData")); + } else { + panic!("Expected SchemaRef::Ref inside all_of"); + } + } else { + panic!("Expected inline schema"); + } + + // Second variant (String) should be inline string schema directly + if let SchemaRef::Inline(simple_variant) = &one_of[1] { + assert_eq!(simple_variant.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected inline schema"); + } + } + + // Edge case: Untagged enum with multi-field tuple variant + #[test] + fn test_untagged_multi_field_tuple_variant() { + let enum_item: syn::ItemEnum = syn::parse_str( + r" + #[serde(untagged)] + enum Message { + Text(String), + Pair(i32, String), + Triple(i32, String, bool), + } + ", + ) + .unwrap(); + + let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + + assert!(schema.discriminator.is_none()); + + let one_of = schema.clone().one_of.expect("one_of missing"); + assert_eq!(one_of.len(), 3); + + // Single-field tuple should be string schema directly + if let SchemaRef::Inline(text_variant) = &one_of[0] { + assert_eq!(text_variant.schema_type, Some(SchemaType::String)); + } + + // Multi-field tuple (Pair) should be array with prefixItems + if let SchemaRef::Inline(pair_variant) = &one_of[1] { + assert_eq!(pair_variant.schema_type, Some(SchemaType::Array)); + let prefix_items = pair_variant + .prefix_items + .as_ref() + .expect("prefix_items missing for Pair"); + assert_eq!(prefix_items.len(), 2); + assert_eq!(pair_variant.min_items, Some(2)); + assert_eq!(pair_variant.max_items, Some(2)); + } + + // Multi-field tuple (Triple) should be array with 3 prefixItems + if let SchemaRef::Inline(triple_variant) = &one_of[2] { + assert_eq!(triple_variant.schema_type, Some(SchemaType::Array)); + let prefix_items = triple_variant + .prefix_items + .as_ref() + .expect("prefix_items missing for Triple"); + assert_eq!(prefix_items.len(), 3); + assert_eq!(triple_variant.min_items, Some(3)); + assert_eq!(triple_variant.max_items, Some(3)); + } + + with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => "untagged_multi_field_tuple" }, { + assert_debug_snapshot!(schema); + }); + } +} diff --git a/crates/vespera_macro/src/parser/schema/enum_schema/unit.rs b/crates/vespera_macro/src/parser/schema/enum_schema/unit.rs new file mode 100644 index 00000000..8fe93565 --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/enum_schema/unit.rs @@ -0,0 +1,40 @@ +use vespera_core::schema::{Schema, SchemaType}; + +use super::super::serde_attrs::{extract_field_rename, rename_field, strip_raw_prefix_owned}; + +/// Parse a simple enum (all unit variants) to a string schema with enum values. +pub(super) fn parse_unit_enum_to_schema( + enum_item: &syn::ItemEnum, + description: Option, + rename_all: Option<&str>, +) -> Schema { + let mut enum_values = Vec::with_capacity(enum_item.variants.len()); + + for variant in &enum_item.variants { + let variant_name = strip_raw_prefix_owned(variant.ident.to_string()); + + // Check for variant-level rename attribute first (takes precedence) + let enum_value = extract_field_rename(&variant.attrs) + .unwrap_or_else(|| rename_field(&variant_name, rename_all)); + + enum_values.push(serde_json::Value::String(enum_value)); + } + + Schema { + schema_type: Some(SchemaType::String), + description, + r#enum: if enum_values.is_empty() { + None + } else { + Some(enum_values) + }, + ..Schema::string() + } +} + +/// Get the variant key (name after rename transformations) +pub(super) fn get_variant_key(variant: &syn::Variant, rename_all: Option<&str>) -> String { + let variant_name = strip_raw_prefix_owned(variant.ident.to_string()); + + extract_field_rename(&variant.attrs).unwrap_or_else(|| rename_field(&variant_name, rename_all)) +} diff --git a/crates/vespera_macro/src/parser/schema/enum_schema/variant.rs b/crates/vespera_macro/src/parser/schema/enum_schema/variant.rs new file mode 100644 index 00000000..56e26716 --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/enum_schema/variant.rs @@ -0,0 +1,148 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; + +use syn::Type; +use vespera_core::schema::{Schema, SchemaRef, SchemaType}; + +use super::super::{ + serde_attrs::{ + extract_doc_comment, extract_field_rename, extract_rename_all, rename_field, + strip_raw_prefix_owned, + }, + type_schema::parse_type_to_schema_ref, +}; + +/// Build properties for a struct variant's fields +pub(super) fn build_struct_variant_properties( + fields_named: &syn::FieldsNamed, + enum_rename_all: Option<&str>, + variant_attrs: &[syn::Attribute], + known_schemas: &HashSet, + struct_definitions: &HashMap, +) -> (BTreeMap, Vec) { + let mut variant_properties = BTreeMap::new(); + let mut variant_required = Vec::with_capacity(fields_named.named.len()); + let variant_rename_all = extract_rename_all(variant_attrs); + + for field in &fields_named.named { + let rust_field_name = field.ident.as_ref().map_or_else( + || "unknown".to_string(), + |i| strip_raw_prefix_owned(i.to_string()), + ); + + // Check for field-level rename attribute first (takes precedence) + let field_name = extract_field_rename(&field.attrs).unwrap_or_else(|| { + rename_field( + &rust_field_name, + variant_rename_all.as_deref().or(enum_rename_all), + ) + }); + + let field_type = &field.ty; + let mut schema_ref = + parse_type_to_schema_ref(field_type, known_schemas, struct_definitions); + + // Extract doc comment from field and set as description + if let Some(doc) = extract_doc_comment(&field.attrs) { + match &mut schema_ref { + SchemaRef::Inline(schema) => { + schema.description = Some(doc); + } + SchemaRef::Ref(_) => { + let ref_schema = std::mem::replace( + &mut schema_ref, + SchemaRef::Inline(Box::new(Schema::object())), + ); + if let SchemaRef::Ref(reference) = ref_schema { + schema_ref = SchemaRef::Inline(Box::new(Schema { + description: Some(doc), + all_of: Some(vec![SchemaRef::Ref(reference)]), + ..Default::default() + })); + } + } + } + } + + variant_properties.insert(field_name.clone(), schema_ref); + + // Check if field is Option + let is_optional = matches!( + field_type, + Type::Path(type_path) + if type_path + .path + .segments + .first() + .is_some_and(|s| s.ident == "Option") + ); + + if !is_optional { + variant_required.push(field_name); + } + } + + (variant_properties, variant_required) +} + +/// Build a schema for a variant's data (tuple or struct fields) +pub(super) fn build_variant_data_schema( + variant: &syn::Variant, + enum_rename_all: Option<&str>, + known_schemas: &HashSet, + struct_definitions: &HashMap, +) -> Option { + match &variant.fields { + syn::Fields::Unit => None, + syn::Fields::Unnamed(fields_unnamed) => { + if fields_unnamed.unnamed.len() == 1 { + // Single field tuple variant - just the inner type + let inner_type = &fields_unnamed.unnamed[0].ty; + Some(parse_type_to_schema_ref( + inner_type, + known_schemas, + struct_definitions, + )) + } else { + // Multiple fields tuple variant - array with prefixItems + let mut tuple_item_schemas = Vec::with_capacity(fields_unnamed.unnamed.len()); + for field in &fields_unnamed.unnamed { + let field_schema = + parse_type_to_schema_ref(&field.ty, known_schemas, struct_definitions); + tuple_item_schemas.push(field_schema); + } + + let tuple_len = tuple_item_schemas.len(); + Some(SchemaRef::Inline(Box::new(Schema { + prefix_items: Some(tuple_item_schemas), + min_items: Some(tuple_len), + max_items: Some(tuple_len), + items: None, + ..Schema::new(SchemaType::Array) + }))) + } + } + syn::Fields::Named(fields_named) => { + let (properties, required) = build_struct_variant_properties( + fields_named, + enum_rename_all, + &variant.attrs, + known_schemas, + struct_definitions, + ); + + Some(SchemaRef::Inline(Box::new(Schema { + properties: if properties.is_empty() { + None + } else { + Some(properties) + }, + required: if required.is_empty() { + None + } else { + Some(required) + }, + ..Schema::object() + }))) + } + } +} diff --git a/crates/vespera_macro/src/parser/schema/serde_attrs.rs b/crates/vespera_macro/src/parser/schema/serde_attrs.rs index 1592d46d..f891ca14 100644 --- a/crates/vespera_macro/src/parser/schema/serde_attrs.rs +++ b/crates/vespera_macro/src/parser/schema/serde_attrs.rs @@ -1,2192 +1,18 @@ -//! Serde attribute extraction utilities for `OpenAPI` schema generation. -//! -//! This module provides functions to extract serde attributes from Rust types -//! to properly generate `OpenAPI` schemas that respect serialization rules. - -/// Extract doc comments from attributes. -/// Returns concatenated doc comment string or None if no doc comments. -pub fn extract_doc_comment(attrs: &[syn::Attribute]) -> Option { - let mut doc_lines = Vec::new(); - - for attr in attrs { - if attr.path().is_ident("doc") - && let syn::Meta::NameValue(meta_nv) = &attr.meta - && let syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(lit_str), - .. - }) = &meta_nv.value - { - let line = lit_str.value(); - // Strip `" / "` or `"/ "` prefixes that can appear when doc-comment - // markers leak through TokenStream → string → parse roundtrips, - // then trim any remaining whitespace. - let trimmed = line - .strip_prefix(" / ") - .or_else(|| line.strip_prefix("/ ")) - .unwrap_or(&line) - .trim(); - doc_lines.push(trimmed.to_string()); - } - } - - if doc_lines.is_empty() { - None - } else { - Some(doc_lines.join("\n")) - } -} - -/// Strips the `r#` prefix from raw identifiers, returning an owned `String`. -/// For the 99% case (no `r#` prefix), returns the input directly with zero extra allocation. -#[allow(clippy::option_if_let_else)] // clippy suggestion doesn't compile: borrow-move conflict -pub fn strip_raw_prefix_owned(ident: String) -> String { - if let Some(stripped) = ident.strip_prefix("r#") { - stripped.to_string() - } else { - ident - } -} - -pub use crate::schema_macro::type_utils::capitalize_first; - -/// Extract a Schema name from a `SeaORM` Entity type path. -/// -/// Converts paths like: -/// - `super::user::Entity` -> "User" -/// - `crate::models::memo::Entity` -> "Memo" -/// -/// The schema name is derived from the module containing Entity, -/// converted to `PascalCase` (first letter uppercase). -pub fn extract_schema_name_from_entity(ty: &syn::Type) -> Option { - match ty { - syn::Type::Path(type_path) => { - let segments: Vec<_> = type_path.path.segments.iter().collect(); - - // Need at least 2 segments: module::Entity - if segments.len() < 2 { - return None; - } - - // Check if last segment is "Entity" - let last = segments.last()?; - if last.ident != "Entity" { - return None; - } - - // Get the second-to-last segment (module name) - let module_segment = segments.get(segments.len() - 2)?; - let module_name = module_segment.ident.to_string(); - - // Convert to PascalCase (capitalize first letter) - // Rust identifiers are guaranteed non-empty, so chars().next() always returns Some - let schema_name = capitalize_first(&module_name); - - Some(schema_name) - } - _ => None, - } -} - -pub fn extract_rename_all(attrs: &[syn::Attribute]) -> Option { - // First check serde attrs (higher priority) - for attr in attrs { - if attr.path().is_ident("serde") { - // Try using parse_nested_meta for robust parsing - let mut found_rename_all = None; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("rename_all") - && let Ok(value) = meta.value() - && let Ok(syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(s), - .. - })) = value.parse::() - { - found_rename_all = Some(s.value()); - } - Ok(()) - }); - if found_rename_all.is_some() { - return found_rename_all; - } - - // Fallback: manual token parsing for complex attribute combinations - let Ok(tokens) = attr.meta.require_list() else { - continue; - }; - let token_str = tokens.tokens.to_string(); - - // Look for rename_all = "..." pattern - if let Some(start) = token_str.find("rename_all") { - let remaining = &token_str[start + "rename_all".len()..]; - if let Some(equals_pos) = remaining.find('=') { - let value_part = remaining[equals_pos + 1..].trim(); - // Extract string value - find the closing quote - if let Some(quote_start) = value_part.find('"') { - let after_quote = &value_part[quote_start + 1..]; - if let Some(quote_end) = after_quote.find('"') { - let value = &after_quote[..quote_end]; - return Some(value.to_string()); - } - } - } - } - } - } - - // Fallback: check for #[try_from_multipart(rename_all = "...")] - for attr in attrs { - if attr.path().is_ident("try_from_multipart") { - let mut found_rename_all = None; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("rename_all") - && let Ok(value) = meta.value() - && let Ok(syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(s), - .. - })) = value.parse::() - { - found_rename_all = Some(s.value()); - } - Ok(()) - }); - if found_rename_all.is_some() { - return found_rename_all; - } - } - } - - None -} - -/// Extract whether `#[serde(transparent)]` is present on a struct. -pub fn extract_transparent(attrs: &[syn::Attribute]) -> bool { - attrs.iter().any(|attr| { - if !attr.path().is_ident("serde") { - return false; - } - - let mut is_transparent = false; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("transparent") { - is_transparent = true; - } - Ok(()) - }); - is_transparent - }) -} - -/// Extract `#[schema(ref = "Name", nullable)]` override from a struct. -pub fn extract_schema_ref_override(attrs: &[syn::Attribute]) -> Option<(String, bool)> { - attrs.iter().find_map(|attr| { - if !attr.path().is_ident("schema") { - return None; - } - - let mut ref_name = None; - let mut nullable = false; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("ref") { - let value = meta.value()?; - let lit: syn::LitStr = value.parse()?; - ref_name = Some(lit.value()); - } else if meta.path.is_ident("nullable") { - nullable = true; - } - Ok(()) - }); - - ref_name.map(|name| (name, nullable)) - }) -} - -pub fn extract_field_rename(attrs: &[syn::Attribute]) -> Option { - // First check serde attrs (higher priority) - for attr in attrs { - if attr.path().is_ident("serde") - && let syn::Meta::List(meta_list) = &attr.meta - { - // Use parse_nested_meta to parse nested attributes - let mut found_rename = None; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("rename") - && let Ok(value) = meta.value() - && let Ok(syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(s), - .. - })) = value.parse::() - { - found_rename = Some(s.value()); - } - Ok(()) - }); - if let Some(rename_value) = found_rename { - return Some(rename_value); - } - - // Fallback: manual token parsing for complex attribute combinations - let tokens = meta_list.tokens.to_string(); - // Look for pattern: rename = "value" (with proper word boundaries) - if let Some(start) = tokens.find("rename") { - // Avoid false positives from rename_all - if tokens[start..].starts_with("rename_all") { - continue; - } - // Check that "rename" is a standalone word (not part of another word) - let before = if start > 0 { &tokens[..start] } else { "" }; - let after_start = start + "rename".len(); - let after = if after_start < tokens.len() { - &tokens[after_start..] - } else { - "" - }; - - let before_char = before.chars().last().unwrap_or(' '); - let after_char = after.chars().next().unwrap_or(' '); - - // Check if rename is a standalone word (preceded by space/comma/paren, followed by space/equals) - if (before_char == ' ' || before_char == ',' || before_char == '(') - && (after_char == ' ' || after_char == '=') - { - // Find the equals sign and extract the quoted value - if let Some(equals_pos) = after.find('=') { - let value_part = &after[equals_pos + 1..].trim(); - // Extract string value (remove quotes) - if let Some(quote_start) = value_part.find('"') { - let after_quote = &value_part[quote_start + 1..]; - if let Some(quote_end) = after_quote.find('"') { - let value = &after_quote[..quote_end]; - return Some(value.to_string()); - } - } - } - } - } - } - } - - // Fallback: check for #[form_data(field_name = "...")] - for attr in attrs { - if attr.path().is_ident("form_data") { - let mut found_field_name = None; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("field_name") - && let Ok(value) = meta.value() - && let Ok(syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(s), - .. - })) = value.parse::() - { - found_field_name = Some(s.value()); - } - Ok(()) - }); - if found_field_name.is_some() { - return found_field_name; - } - } - } - - None -} - -/// Extract skip attribute from field attributes -/// Returns true if #[serde(skip)] is present -pub fn extract_skip(attrs: &[syn::Attribute]) -> bool { - for attr in attrs { - if attr.path().is_ident("serde") - && let syn::Meta::List(meta_list) = &attr.meta - { - let tokens = meta_list.tokens.to_string(); - // Check for "skip" (not part of skip_serializing_if or skip_deserializing) - if tokens.contains("skip") { - // Make sure it's not skip_serializing_if or skip_deserializing - if !tokens.contains("skip_serializing_if") && !tokens.contains("skip_deserializing") - { - // Check if it's a standalone "skip" - let skip_pos = tokens.find("skip"); - if let Some(pos) = skip_pos { - let before = if pos > 0 { &tokens[..pos] } else { "" }; - let after = &tokens[pos + "skip".len()..]; - // Check if skip is not part of another word - let before_char = before.chars().last().unwrap_or(' '); - let after_char = after.chars().next().unwrap_or(' '); - if (before_char == ' ' || before_char == ',' || before_char == '(') - && (after_char == ' ' || after_char == ',' || after_char == ')') - { - return true; - } - } - } - } - } - } - false -} - -/// Extract flatten attribute from field attributes -/// Returns true if #[serde(flatten)] is present -pub fn extract_flatten(attrs: &[syn::Attribute]) -> bool { - for attr in attrs { - if attr.path().is_ident("serde") { - // Try using parse_nested_meta for robust parsing - let mut found = false; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("flatten") { - found = true; - } - Ok(()) - }); - if found { - return true; - } - - // Fallback: manual token parsing for complex attribute combinations - if let syn::Meta::List(meta_list) = &attr.meta { - let tokens = meta_list.tokens.to_string(); - // Check for "flatten" as a standalone word - if let Some(pos) = tokens.find("flatten") { - let before = if pos > 0 { &tokens[..pos] } else { "" }; - let after = &tokens[pos + "flatten".len()..]; - let before_char = before.chars().last().unwrap_or(' '); - let after_char = after.chars().next().unwrap_or(' '); - if (before_char == ' ' || before_char == ',' || before_char == '(') - && (after_char == ' ' || after_char == ',' || after_char == ')') - { - return true; - } - } - } - } - } - false -} - -/// Extract `skip_serializing_if` attribute from field attributes -/// Returns true if #[`serde(skip_serializing_if` = "...")] is present -pub fn extract_skip_serializing_if(attrs: &[syn::Attribute]) -> bool { - for attr in attrs { - if attr.path().is_ident("serde") - && let syn::Meta::List(meta_list) = &attr.meta - { - let mut found = false; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("skip_serializing_if") { - found = true; - } - Ok(()) - }); - if found { - return true; - } - - // Fallback: check tokens string for complex attribute combinations - let tokens = meta_list.tokens.to_string(); - if tokens.contains("skip_serializing_if") { - return true; - } - } - } - false -} - -/// Check whether the `"default"` substring at index `start` of `tokens` -/// is delimited by valid meta-list separators on both sides (whitespace, -/// `,`, `(`, or `)`). Pulled out of `extract_default` so the fallback -/// path gets its own basic block and shows up cleanly in coverage. -fn is_standalone_default(tokens: &str, start: usize, remaining: &str) -> bool { - let before = if start > 0 { &tokens[..start] } else { "" }; - let before_char = before.chars().last().unwrap_or(' '); - let after_char = remaining.chars().next().unwrap_or(' '); - let before_ok = before_char == ' ' || before_char == ',' || before_char == '('; - let after_ok = after_char == ' ' || after_char == ',' || after_char == ')'; - before_ok && after_ok -} - -/// Extract default attribute from field attributes -/// Returns: -/// - Some(None) if #[serde(default)] is present (no function) -/// - `Some(Some(function_name))` if #[serde(default = "`function_name`")] is present -/// - None if no default attribute is present -#[allow(clippy::option_option)] -pub fn extract_default(attrs: &[syn::Attribute]) -> Option> { - for attr in attrs { - if attr.path().is_ident("serde") - && let syn::Meta::List(meta_list) = &attr.meta - { - let mut found_default: Option> = None; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("default") { - // Check if it has a value (default = "function_name") - if let Ok(value) = meta.value() { - if let Ok(syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(s), - .. - })) = value.parse::() - { - found_default = Some(Some(s.value())); - } - } else { - // Just "default" without value - found_default = Some(None); - } - } - Ok(()) - }); - if found_default.is_none() { - // Fallback: manual token parsing for complex attribute combinations - found_default = scan_default_from_raw_tokens(&meta_list.tokens.to_string()); - } - if let Some(default_value) = found_default { - return Some(default_value); - } - } - } - None -} - -/// Scan `tokens` (the raw `to_string()` rendering of a `#[serde(...)]` -/// argument list) for a `default` keyword that survived the -/// `parse_nested_meta` pass. Returns the same `Option>` -/// shape `extract_default` consumes: -/// - `Some(Some(fn_name))` for `default = "fn_name"` -/// - `Some(None)` for a bare standalone `default` -/// - `None` when no `default` keyword could be confidently identified -/// -/// Pulled out of `extract_default` so the fallback paths each get their -/// own basic block and show up in coverage. -#[allow(clippy::option_option)] -fn scan_default_from_raw_tokens(tokens: &str) -> Option> { - let start = tokens.find("default")?; - let remaining = &tokens[start + "default".len()..]; - if remaining.trim_start().starts_with('=') { - // default = "function_name" - let after_equals = remaining - .trim_start() - .strip_prefix('=') - .unwrap_or("") - .trim_start(); - let quote_start = after_equals.find('"')?; - let after_quote = &after_equals[quote_start + 1..]; - let quote_end = after_quote.find('"')?; - Some(Some(after_quote[..quote_end].to_string())) - } else if is_standalone_default(tokens, start, remaining) { - Some(None) - } else { - None - } -} - -#[allow(clippy::too_many_lines)] -pub fn rename_field(field_name: &str, rename_all: Option<&str>) -> String { - // "lowercase", "UPPERCASE", "PascalCase", "camelCase", "snake_case", "SCREAMING_SNAKE_CASE", "kebab-case", "SCREAMING-KEBAB-CASE" - match rename_all { - Some("camelCase") => { - // Convert snake_case or PascalCase to camelCase - let mut result = String::new(); - let mut capitalize_next = false; - let mut in_first_word = true; - let chars: Vec = field_name.chars().collect(); - - for (i, &ch) in chars.iter().enumerate() { - if ch == '_' { - capitalize_next = true; - in_first_word = false; - continue; - } - if in_first_word { - // In first word: lowercase until we hit a word boundary - // Word boundary: uppercase char followed by lowercase (e.g., "XMLParser" -> "P" starts new word) - let next_is_lower = chars.get(i + 1).is_some_and(|c| c.is_lowercase()); - if ch.is_uppercase() && next_is_lower && i > 0 { - // This uppercase starts a new word (e.g., 'P' in "XMLParser") - in_first_word = false; - result.push(ch); - } else { - // Still in first word, lowercase it - result.push(ch.to_ascii_lowercase()); - } - continue; - } - if capitalize_next { - result.push(ch.to_ascii_uppercase()); - capitalize_next = false; - continue; - } - result.push(ch); - } - result - } - Some("snake_case") => { - // Convert camelCase to snake_case - let mut result = String::new(); - for (i, ch) in field_name.chars().enumerate() { - if ch.is_uppercase() && i > 0 { - result.push('_'); - } - result.push(ch.to_ascii_lowercase()); - } - result - } - Some("kebab-case") => { - // Convert snake_case or Camel/PascalCase to kebab-case (lowercase with hyphens) - let mut result = String::new(); - for (i, ch) in field_name.chars().enumerate() { - if ch.is_uppercase() { - if i > 0 && !result.ends_with('-') { - result.push('-'); - } - result.push(ch.to_ascii_lowercase()); - } else if ch == '_' { - result.push('-'); - } else { - result.push(ch); - } - } - result - } - Some("PascalCase") => { - // Convert snake_case to PascalCase - let mut result = String::new(); - let mut capitalize_next = true; - for ch in field_name.chars() { - if ch == '_' { - capitalize_next = true; - } else if capitalize_next { - result.push(ch.to_ascii_uppercase()); - capitalize_next = false; - } else { - result.push(ch); - } - } - result - } - Some("lowercase") => { - // Convert to lowercase - field_name.to_lowercase() - } - Some("UPPERCASE") => { - // Convert to UPPERCASE - field_name.to_uppercase() - } - Some("SCREAMING_SNAKE_CASE") => { - // Convert to SCREAMING_SNAKE_CASE - // If already in SCREAMING_SNAKE_CASE format, return as is - if field_name.chars().all(|c| c.is_uppercase() || c == '_') && field_name.contains('_') - { - return field_name.to_string(); - } - // First convert to snake_case if needed, then uppercase - let mut snake_case = String::new(); - for (i, ch) in field_name.chars().enumerate() { - if ch.is_uppercase() && i > 0 && !snake_case.ends_with('_') { - snake_case.push('_'); - } - if ch != '_' && ch != '-' { - snake_case.push(ch.to_ascii_lowercase()); - } else if ch == '_' { - snake_case.push('_'); - } - } - snake_case.to_uppercase() - } - Some("SCREAMING-KEBAB-CASE") => { - // Convert to SCREAMING-KEBAB-CASE - // First convert to kebab-case if needed, then uppercase - let mut kebab_case = String::new(); - for (i, ch) in field_name.chars().enumerate() { - if ch.is_uppercase() - && i > 0 - && !kebab_case.ends_with('-') - && !kebab_case.ends_with('_') - { - kebab_case.push('-'); - } - if ch == '_' { - kebab_case.push('-'); - } else if ch != '-' { - kebab_case.push(ch.to_ascii_lowercase()); - } else { - kebab_case.push('-'); - } - } - kebab_case.to_uppercase() - } - _ => field_name.to_string(), - } -} - -/// Serde enum representation types -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SerdeEnumRepr { - /// Default externally tagged: `{"VariantName": {...}}` - ExternallyTagged, - /// Internally tagged: `{"type": "VariantName", ...fields...}` - /// Only valid for struct and unit variants - InternallyTagged { tag: String }, - /// Adjacently tagged: `{"type": "VariantName", "data": {...}}` - AdjacentlyTagged { tag: String, content: String }, - /// Untagged: `{...fields...}` (no tag, first matching variant wins) - Untagged, -} - -/// Extract serde enum representation from attributes. -/// -/// Detects the enum tagging strategy from serde attributes: -/// - `#[serde(tag = "type")]` → `InternallyTagged` -/// - `#[serde(tag = "type", content = "data")]` → `AdjacentlyTagged` -/// - `#[serde(untagged)]` → Untagged -/// - No relevant attributes → `ExternallyTagged` (default) -pub fn extract_enum_repr(attrs: &[syn::Attribute]) -> SerdeEnumRepr { - let tag = extract_tag(attrs); - let content = extract_content(attrs); - let untagged = extract_untagged(attrs); - - if untagged { - SerdeEnumRepr::Untagged - } else if let Some(tag_name) = tag { - if let Some(content_name) = content { - SerdeEnumRepr::AdjacentlyTagged { - tag: tag_name, - content: content_name, - } - } else { - SerdeEnumRepr::InternallyTagged { tag: tag_name } - } - } else { - SerdeEnumRepr::ExternallyTagged - } -} - -/// Extract tag attribute from serde container attributes -/// Returns the tag name if `#[serde(tag = "...")]` is present -pub fn extract_tag(attrs: &[syn::Attribute]) -> Option { - for attr in attrs { - if attr.path().is_ident("serde") { - let mut found_tag = None; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("tag") - && let Ok(value) = meta.value() - && let Ok(syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(s), - .. - })) = value.parse::() - { - found_tag = Some(s.value()); - } - Ok(()) - }); - if found_tag.is_some() { - return found_tag; - } - - // Fallback: manual token parsing - let Ok(tokens) = attr.meta.require_list() else { - continue; - }; - let token_str = tokens.tokens.to_string(); - - if let Some(start) = token_str.find("tag") { - // Ensure it's "tag" not "untagged" - let before = if start > 0 { &token_str[..start] } else { "" }; - let before_char = before.chars().last().unwrap_or(' '); - if before_char != 'n' { - // Not "untagged" - let remaining = &token_str[start + "tag".len()..]; - if let Some(equals_pos) = remaining.find('=') { - let value_part = remaining[equals_pos + 1..].trim(); - if let Some(quote_start) = value_part.find('"') { - let after_quote = &value_part[quote_start + 1..]; - if let Some(quote_end) = after_quote.find('"') { - let value = &after_quote[..quote_end]; - return Some(value.to_string()); - } - } - } - } - } - } - } - None -} - -/// Extract content attribute from serde container attributes -/// Returns the content name if `#[serde(content = "...")]` is present -pub fn extract_content(attrs: &[syn::Attribute]) -> Option { - for attr in attrs { - if attr.path().is_ident("serde") { - let mut found_content = None; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("content") - && let Ok(value) = meta.value() - && let Ok(syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(s), - .. - })) = value.parse::() - { - found_content = Some(s.value()); - } - Ok(()) - }); - if found_content.is_some() { - return found_content; - } - - // Fallback: manual token parsing - let Ok(tokens) = attr.meta.require_list() else { - continue; - }; - let token_str = tokens.tokens.to_string(); - - if let Some(start) = token_str.find("content") { - let remaining = &token_str[start + "content".len()..]; - if let Some(equals_pos) = remaining.find('=') { - let value_part = remaining[equals_pos + 1..].trim(); - if let Some(quote_start) = value_part.find('"') { - let after_quote = &value_part[quote_start + 1..]; - if let Some(quote_end) = after_quote.find('"') { - let value = &after_quote[..quote_end]; - return Some(value.to_string()); - } - } - } - } - } - } - None -} - -/// Extract untagged attribute from serde container attributes -/// Returns true if `#[serde(untagged)]` is present -pub fn extract_untagged(attrs: &[syn::Attribute]) -> bool { - for attr in attrs { - if attr.path().is_ident("serde") { - let mut found = false; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("untagged") { - found = true; - } - Ok(()) - }); - if found { - return true; - } - - // Fallback: manual token parsing - if let syn::Meta::List(meta_list) = &attr.meta { - let tokens = meta_list.tokens.to_string(); - if let Some(pos) = tokens.find("untagged") { - let before = if pos > 0 { &tokens[..pos] } else { "" }; - let after = &tokens[pos + "untagged".len()..]; - let before_char = before.chars().last().unwrap_or(' '); - let after_char = after.chars().next().unwrap_or(' '); - if (before_char == ' ' || before_char == ',' || before_char == '(') - && (after_char == ' ' || after_char == ',' || after_char == ')') - { - return true; - } - } - } - } - } - false -} - -#[cfg(test)] -mod tests { - #![allow(clippy::option_option)] - - use rstest::rstest; - - use super::*; - - #[rstest] - // camelCase tests (snake_case input) - #[case("user_name", Some("camelCase"), "userName")] - #[case("first_name", Some("camelCase"), "firstName")] - #[case("last_name", Some("camelCase"), "lastName")] - #[case("user_id", Some("camelCase"), "userId")] - #[case("api_key", Some("camelCase"), "apiKey")] - #[case("already_camel", Some("camelCase"), "alreadyCamel")] - // camelCase tests (PascalCase input) - #[case("UserName", Some("camelCase"), "userName")] - #[case("UserCreated", Some("camelCase"), "userCreated")] - #[case("FirstName", Some("camelCase"), "firstName")] - #[case("ID", Some("camelCase"), "id")] - #[case("XMLParser", Some("camelCase"), "xmlParser")] - #[case("HTTPSConnection", Some("camelCase"), "httpsConnection")] - // snake_case tests - #[case("userName", Some("snake_case"), "user_name")] - #[case("firstName", Some("snake_case"), "first_name")] - #[case("lastName", Some("snake_case"), "last_name")] - #[case("userId", Some("snake_case"), "user_id")] - #[case("apiKey", Some("snake_case"), "api_key")] - #[case("already_snake", Some("snake_case"), "already_snake")] - // kebab-case tests - #[case("user_name", Some("kebab-case"), "user-name")] - #[case("first_name", Some("kebab-case"), "first-name")] - #[case("last_name", Some("kebab-case"), "last-name")] - #[case("user_id", Some("kebab-case"), "user-id")] - #[case("api_key", Some("kebab-case"), "api-key")] - #[case("already-kebab", Some("kebab-case"), "already-kebab")] - // PascalCase tests - #[case("user_name", Some("PascalCase"), "UserName")] - #[case("first_name", Some("PascalCase"), "FirstName")] - #[case("last_name", Some("PascalCase"), "LastName")] - #[case("user_id", Some("PascalCase"), "UserId")] - #[case("api_key", Some("PascalCase"), "ApiKey")] - #[case("AlreadyPascal", Some("PascalCase"), "AlreadyPascal")] - // lowercase tests - #[case("UserName", Some("lowercase"), "username")] - #[case("FIRST_NAME", Some("lowercase"), "first_name")] - #[case("lastName", Some("lowercase"), "lastname")] - #[case("User_ID", Some("lowercase"), "user_id")] - #[case("API_KEY", Some("lowercase"), "api_key")] - #[case("already_lower", Some("lowercase"), "already_lower")] - // UPPERCASE tests - #[case("user_name", Some("UPPERCASE"), "USER_NAME")] - #[case("firstName", Some("UPPERCASE"), "FIRSTNAME")] - #[case("LastName", Some("UPPERCASE"), "LASTNAME")] - #[case("user_id", Some("UPPERCASE"), "USER_ID")] - #[case("apiKey", Some("UPPERCASE"), "APIKEY")] - #[case("ALREADY_UPPER", Some("UPPERCASE"), "ALREADY_UPPER")] - // SCREAMING_SNAKE_CASE tests - #[case("user_name", Some("SCREAMING_SNAKE_CASE"), "USER_NAME")] - #[case("firstName", Some("SCREAMING_SNAKE_CASE"), "FIRST_NAME")] - #[case("LastName", Some("SCREAMING_SNAKE_CASE"), "LAST_NAME")] - #[case("user_id", Some("SCREAMING_SNAKE_CASE"), "USER_ID")] - #[case("apiKey", Some("SCREAMING_SNAKE_CASE"), "API_KEY")] - #[case("ALREADY_SCREAMING", Some("SCREAMING_SNAKE_CASE"), "ALREADY_SCREAMING")] - // SCREAMING-KEBAB-CASE tests - #[case("user_name", Some("SCREAMING-KEBAB-CASE"), "USER-NAME")] - #[case("firstName", Some("SCREAMING-KEBAB-CASE"), "FIRST-NAME")] - #[case("LastName", Some("SCREAMING-KEBAB-CASE"), "LAST-NAME")] - #[case("user_id", Some("SCREAMING-KEBAB-CASE"), "USER-ID")] - #[case("apiKey", Some("SCREAMING-KEBAB-CASE"), "API-KEY")] - #[case("already-kebab", Some("SCREAMING-KEBAB-CASE"), "ALREADY-KEBAB")] - // None tests (no transformation) - #[case("user_name", None, "user_name")] - #[case("firstName", None, "firstName")] - #[case("LastName", None, "LastName")] - #[case("user-id", None, "user-id")] - fn test_rename_field( - #[case] field_name: &str, - #[case] rename_all: Option<&str>, - #[case] expected: &str, - ) { - assert_eq!(rename_field(field_name, rename_all), expected); - } - - #[rstest] - #[case(r#"#[serde(rename_all = "camelCase")] struct Foo;"#, Some("camelCase"))] - #[case( - r#"#[serde(rename_all = "snake_case")] struct Foo;"#, - Some("snake_case") - )] - #[case( - r#"#[serde(rename_all = "kebab-case")] struct Foo;"#, - Some("kebab-case") - )] - #[case( - r#"#[serde(rename_all = "PascalCase")] struct Foo;"#, - Some("PascalCase") - )] - // Multiple attributes - this is the bug case - #[case( - r#"#[serde(rename_all = "camelCase", default)] struct Foo;"#, - Some("camelCase") - )] - #[case( - r#"#[serde(default, rename_all = "snake_case")] struct Foo;"#, - Some("snake_case") - )] - #[case(r#"#[serde(rename_all = "kebab-case", skip_serializing_if = "Option::is_none")] struct Foo;"#, Some("kebab-case"))] - // No rename_all - #[case(r"#[serde(default)] struct Foo;", None)] - #[case(r"#[derive(Debug)] struct Foo;", None)] - fn test_extract_rename_all(#[case] item_src: &str, #[case] expected: Option<&str>) { - let item: syn::ItemStruct = syn::parse_str(item_src).unwrap(); - let result = extract_rename_all(&item.attrs); - assert_eq!(result.as_deref(), expected); - } - - #[test] - fn test_extract_rename_all_enum_with_deny_unknown_fields() { - let enum_item: syn::ItemEnum = syn::parse_str( - r#" - #[serde(rename_all = "camelCase", deny_unknown_fields)] - enum Foo { A, B } - "#, - ) - .unwrap(); - let result = extract_rename_all(&enum_item.attrs); - assert_eq!(result.as_deref(), Some("camelCase")); - } - - // Tests for extract_field_rename function - #[rstest] - #[case(r#"#[serde(rename = "custom_name")] field: i32"#, Some("custom_name"))] - #[case(r#"#[serde(rename = "userId")] field: i32"#, Some("userId"))] - #[case(r#"#[serde(rename = "ID")] field: i32"#, Some("ID"))] - #[case(r"#[serde(default)] field: i32", None)] - #[case(r"#[serde(skip)] field: i32", None)] - #[case(r"field: i32", None)] - // rename_all should NOT be extracted as rename - #[case(r#"#[serde(rename_all = "camelCase")] field: i32"#, None)] - // Multiple attributes - #[case(r#"#[serde(rename = "custom", default)] field: i32"#, Some("custom"))] - #[case( - r#"#[serde(default, rename = "my_field")] field: i32"#, - Some("my_field") - )] - fn test_extract_field_rename(#[case] field_src: &str, #[case] expected: Option<&str>) { - // Parse field from struct context - let struct_src = format!("struct Foo {{ {field_src} }}"); - let item: syn::ItemStruct = syn::parse_str(&struct_src).unwrap(); - if let syn::Fields::Named(fields) = &item.fields { - let field = fields.named.first().unwrap(); - let result = extract_field_rename(&field.attrs); - assert_eq!(result.as_deref(), expected, "Failed for: {field_src}"); - } - } - - // Tests for extract_skip function - #[rstest] - #[case(r"#[serde(skip)] field: i32", true)] - #[case(r"#[serde(default)] field: i32", false)] - #[case(r#"#[serde(rename = "x")] field: i32"#, false)] - #[case(r"field: i32", false)] - // skip_serializing_if should NOT be treated as skip - #[case( - r#"#[serde(skip_serializing_if = "Option::is_none")] field: i32"#, - false - )] - // skip_deserializing should NOT be treated as skip - #[case(r"#[serde(skip_deserializing)] field: i32", false)] - // Combined attributes - #[case(r"#[serde(skip, default)] field: i32", true)] - #[case(r"#[serde(default, skip)] field: i32", true)] - fn test_extract_skip(#[case] field_src: &str, #[case] expected: bool) { - let struct_src = format!("struct Foo {{ {field_src} }}"); - let item: syn::ItemStruct = syn::parse_str(&struct_src).unwrap(); - if let syn::Fields::Named(fields) = &item.fields { - let field = fields.named.first().unwrap(); - let result = extract_skip(&field.attrs); - assert_eq!(result, expected, "Failed for: {field_src}"); - } - } - - // Tests for extract_flatten function - #[rstest] - #[case(r"#[serde(flatten)] field: i32", true)] - #[case(r"#[serde(default)] field: i32", false)] - #[case(r#"#[serde(rename = "x")] field: i32"#, false)] - #[case(r"field: i32", false)] - // Combined attributes - #[case(r"#[serde(flatten, default)] field: i32", true)] - #[case(r"#[serde(default, flatten)] field: i32", true)] - fn test_extract_flatten(#[case] field_src: &str, #[case] expected: bool) { - let struct_src = format!("struct Foo {{ {field_src} }}"); - let item: syn::ItemStruct = syn::parse_str(&struct_src).unwrap(); - if let syn::Fields::Named(fields) = &item.fields { - let field = fields.named.first().unwrap(); - let result = extract_flatten(&field.attrs); - assert_eq!(result, expected, "Failed for: {field_src}"); - } - } - - // Tests for extract_skip_serializing_if function - #[rstest] - #[case( - r#"#[serde(skip_serializing_if = "Option::is_none")] field: i32"#, - true - )] - #[case(r#"#[serde(skip_serializing_if = "is_zero")] field: i32"#, true)] - #[case(r"#[serde(default)] field: i32", false)] - #[case(r"#[serde(skip)] field: i32", false)] - #[case(r"field: i32", false)] - fn test_extract_skip_serializing_if(#[case] field_src: &str, #[case] expected: bool) { - let struct_src = format!("struct Foo {{ {field_src} }}"); - let item: syn::ItemStruct = syn::parse_str(&struct_src).unwrap(); - if let syn::Fields::Named(fields) = &item.fields { - let field = fields.named.first().unwrap(); - let result = extract_skip_serializing_if(&field.attrs); - assert_eq!(result, expected, "Failed for: {field_src}"); - } - } - - // Tests for extract_default function - #[rstest] - // Simple default (no function) - #[case(r"#[serde(default)] field: i32", Some(None))] - // Default with function name - #[case( - r#"#[serde(default = "default_value")] field: i32"#, - Some(Some("default_value")) - )] - #[case( - r#"#[serde(default = "Default::default")] field: i32"#, - Some(Some("Default::default")) - )] - // No default - #[case(r"#[serde(skip)] field: i32", None)] - #[case(r#"#[serde(rename = "x")] field: i32"#, None)] - #[case(r"field: i32", None)] - // Combined attributes - #[case( - r#"#[serde(default, skip_serializing_if = "Option::is_none")] field: i32"#, - Some(None) - )] - #[case( - r#"#[serde(skip_serializing_if = "Option::is_none", default = "my_default")] field: i32"#, - Some(Some("my_default")) - )] - fn test_extract_default( - #[case] field_src: &str, - #[case] - #[allow(clippy::option_option)] - expected: Option>, - ) { - let struct_src = format!("struct Foo {{ {field_src} }}"); - let item: syn::ItemStruct = syn::parse_str(&struct_src).unwrap(); - if let syn::Fields::Named(fields) = &item.fields { - let field = fields.named.first().unwrap(); - let result = extract_default(&field.attrs); - let expected_owned = expected.map(|o| o.map(std::string::ToString::to_string)); - assert_eq!(result, expected_owned, "Failed for: {field_src}"); - } - } - - // Test camelCase transformation with mixed characters - #[test] - fn test_rename_field_camelcase_with_digits() { - // Tests the regular character branch in camelCase - let result = rename_field("user_id_123", Some("camelCase")); - assert_eq!(result, "userId123"); - - let result = rename_field("get_user_by_id", Some("camelCase")); - assert_eq!(result, "getUserById"); - } - - // Tests for extract_doc_comment function - #[test] - fn test_extract_doc_comment_single_line() { - let attrs: Vec = syn::parse_quote! { - #[doc = " This is a doc comment"] - }; - let result = extract_doc_comment(&attrs); - assert_eq!(result, Some("This is a doc comment".to_string())); - } - - #[test] - fn test_extract_doc_comment_multi_line() { - let attrs: Vec = syn::parse_quote! { - #[doc = " First line"] - #[doc = " Second line"] - #[doc = " Third line"] - }; - let result = extract_doc_comment(&attrs); - assert_eq!( - result, - Some("First line\nSecond line\nThird line".to_string()) - ); - } - - #[test] - fn test_extract_doc_comment_no_leading_space() { - let attrs: Vec = syn::parse_quote! { - #[doc = "No leading space"] - }; - let result = extract_doc_comment(&attrs); - assert_eq!(result, Some("No leading space".to_string())); - } - - #[test] - fn test_extract_doc_comment_empty() { - let attrs: Vec = vec![]; - let result = extract_doc_comment(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_extract_doc_comment_with_non_doc_attrs() { - let attrs: Vec = syn::parse_quote! { - #[derive(Debug)] - #[doc = " The doc comment"] - #[serde(rename = "test")] - }; - let result = extract_doc_comment(&attrs); - assert_eq!(result, Some("The doc comment".to_string())); - } - - // Tests for extract_schema_name_from_entity function - #[test] - fn test_extract_schema_name_from_entity_super_path() { - let ty: syn::Type = syn::parse_str("super::user::Entity").unwrap(); - let result = extract_schema_name_from_entity(&ty); - assert_eq!(result, Some("User".to_string())); - } - - #[test] - fn test_extract_schema_name_from_entity_crate_path() { - let ty: syn::Type = syn::parse_str("crate::models::memo::Entity").unwrap(); - let result = extract_schema_name_from_entity(&ty); - assert_eq!(result, Some("Memo".to_string())); - } - - #[test] - fn test_extract_schema_name_from_entity_not_entity() { - let ty: syn::Type = syn::parse_str("crate::models::user::Model").unwrap(); - let result = extract_schema_name_from_entity(&ty); - assert_eq!(result, None); - } - - #[test] - fn test_extract_schema_name_from_entity_single_segment() { - let ty: syn::Type = syn::parse_str("Entity").unwrap(); - let result = extract_schema_name_from_entity(&ty); - assert_eq!(result, None); - } - - #[test] - fn test_extract_schema_name_from_entity_non_path_type() { - let ty: syn::Type = syn::parse_str("&str").unwrap(); - let result = extract_schema_name_from_entity(&ty); - assert_eq!(result, None); - } - - #[test] - fn test_extract_schema_name_from_entity_empty_module_name() { - // Tests the branch where module name has no characters (edge case) - let ty: syn::Type = syn::parse_str("super::some_module::Entity").unwrap(); - let result = extract_schema_name_from_entity(&ty); - assert_eq!(result, Some("Some_module".to_string())); - } - - // Test rename_field with unknown/invalid rename_all format - should return original field name - #[test] - fn test_rename_field_unknown_format() { - // Unknown format should return the original field name unchanged - let result = rename_field("my_field", Some("unknown_format")); - assert_eq!(result, "my_field"); - - let result = rename_field("myField", Some("invalid")); - assert_eq!(result, "myField"); - - let result = rename_field("test_name", Some("not_a_real_format")); - assert_eq!(result, "test_name"); - } - - /// Test strip_raw_prefix_owned function - #[test] - fn test_strip_raw_prefix_owned() { - assert_eq!(strip_raw_prefix_owned("r#type".to_string()), "type"); - assert_eq!(strip_raw_prefix_owned("r#match".to_string()), "match"); - assert_eq!(strip_raw_prefix_owned("normal".to_string()), "normal"); - assert_eq!(strip_raw_prefix_owned("r#".to_string()), ""); - } - - // Tests using programmatically created attributes - mod fallback_parsing_tests { - use proc_macro2::{Span, TokenStream}; - use quote::quote; - - use super::*; - - /// Helper to create attributes by parsing a struct with the given serde attributes - fn get_struct_attrs(serde_content: &str) -> Vec { - let src = format!(r"#[serde({serde_content})] struct Foo;"); - let item: syn::ItemStruct = syn::parse_str(&src).unwrap(); - item.attrs - } - - /// Helper to create field attributes by parsing a struct with the field - fn get_field_attrs(serde_content: &str) -> Vec { - let src = format!(r"struct Foo {{ #[serde({serde_content})] field: i32 }}"); - let item: syn::ItemStruct = syn::parse_str(&src).unwrap(); - if let syn::Fields::Named(fields) = &item.fields { - fields.named.first().unwrap().attrs.clone() - } else { - vec![] - } - } - - /// Create a serde attribute with programmatic tokens - fn create_attr_with_raw_tokens(tokens: TokenStream) -> syn::Attribute { - syn::Attribute { - pound_token: syn::token::Pound::default(), - style: syn::AttrStyle::Outer, - bracket_token: syn::token::Bracket::default(), - meta: syn::Meta::List(syn::MetaList { - path: syn::Path::from(syn::Ident::new("serde", Span::call_site())), - delimiter: syn::MacroDelimiter::Paren(syn::token::Paren::default()), - tokens, - }), - } - } - - /// Test extract_rename_all fallback by creating an attribute where - /// parse_nested_meta succeeds but doesn't find rename_all in the expected format - #[test] - fn test_extract_rename_all_fallback_path() { - // Standard path - parse_nested_meta should work - let attrs = get_struct_attrs(r#"rename_all = "camelCase""#); - let result = extract_rename_all(&attrs); - assert_eq!(result.as_deref(), Some("camelCase")); - } - - /// Test extract_field_rename fallback - #[test] - fn test_extract_field_rename_fallback_path() { - // Standard path - let attrs = get_field_attrs(r#"rename = "myField""#); - let result = extract_field_rename(&attrs); - assert_eq!(result.as_deref(), Some("myField")); - } - - /// Test extract_skip_serializing_if with fallback token check - #[test] - fn test_extract_skip_serializing_if_fallback_path() { - let attrs = get_field_attrs(r#"skip_serializing_if = "Option::is_none""#); - let result = extract_skip_serializing_if(&attrs); - assert!(result); - } - - /// Test extract_default standalone fallback - #[test] - fn test_extract_default_standalone_fallback_path() { - // Simple default without function - let attrs = get_field_attrs(r"default"); - let result = extract_default(&attrs); - assert_eq!(result, Some(None)); - } - - /// Test extract_default fallback when parse_nested_meta can't see `default` - /// at the top level — forces the manual token scan to catch it. - #[test] - fn test_extract_default_standalone_fallback_when_nested_meta_fails() { - // Construct an attribute whose token stream begins with garbage - // that `parse_nested_meta` will refuse to parse (a stray `@` - // before the first key). Because the parser bails immediately, - // the callback for `default` never fires, and the manual - // token-string fallback at the end of `extract_default` is the - // only path that detects the standalone `default` keyword. - let tokens: TokenStream = "@bogus, default".parse().expect("token stream parses"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_default(&[attr]); - assert_eq!( - result, - Some(None), - "fallback path must detect bare `default`" - ); - } - - /// Test that the fallback's "default appears as a substring inside - /// another identifier" branch returns None (no false-positive - /// match). Exercises the trailing `None` arm of - /// `scan_default_from_raw_tokens` (substring found, but neither - /// `=` follows nor delimiter chars surround it). - #[test] - fn test_extract_default_substring_in_identifier_is_not_a_match() { - // `field_default` contains "default" but as a suffix of an - // identifier — `before_char` is `_`, not one of the valid - // delimiters, so the standalone check fails. - let tokens: TokenStream = "@bogus, field_default" - .parse() - .expect("token stream parses"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_default(&[attr]); - assert_eq!( - result, None, - "embedded 'default' substring must not register as default" - ); - } - - /// Test extract_default with function fallback - #[test] - fn test_extract_default_with_function_fallback_path() { - let attrs = get_field_attrs(r#"default = "my_default_fn""#); - let result = extract_default(&attrs); - assert_eq!(result, Some(Some("my_default_fn".to_string()))); - } - - /// Test that rename_all is NOT confused with rename - #[test] - fn test_extract_field_rename_avoids_rename_all() { - let attrs = get_field_attrs(r#"rename_all = "camelCase""#); - let result = extract_field_rename(&attrs); - assert_eq!(result, None); // Should NOT extract rename_all as rename - } - - /// Test empty serde attribute - #[test] - fn test_extract_functions_with_empty_serde() { - let item: syn::ItemStruct = syn::parse_str(r"#[serde()] struct Foo;").unwrap(); - assert_eq!(extract_rename_all(&item.attrs), None); - } - - /// Test non-serde attribute is ignored - #[test] - fn test_extract_functions_ignore_non_serde() { - let item: syn::ItemStruct = syn::parse_str(r"#[derive(Debug)] struct Foo;").unwrap(); - assert_eq!(extract_rename_all(&item.attrs), None); - assert_eq!(extract_field_rename(&item.attrs), None); - } - - /// Test serde attribute that is not a list (e.g., #[serde]) - #[test] - fn test_extract_rename_all_non_list_serde() { - // #[serde] without parentheses - this should just be ignored - let item: syn::ItemStruct = syn::parse_str(r"#[serde] struct Foo;").unwrap(); - let result = extract_rename_all(&item.attrs); - assert_eq!(result, None); - } - - /// Test extract_field_rename with complex attribute - #[test] - fn test_extract_field_rename_complex_attr() { - let attrs = get_field_attrs( - r#"default, rename = "field_name", skip_serializing_if = "Option::is_none""#, - ); - let result = extract_field_rename(&attrs); - assert_eq!(result.as_deref(), Some("field_name")); - } - - /// Test extract_rename_all with multiple serde attributes on same item - #[test] - fn test_extract_rename_all_multiple_serde_attrs() { - let item: syn::ItemStruct = syn::parse_str( - r#" - #[serde(default)] - #[serde(rename_all = "snake_case")] - struct Foo; - "#, - ) - .unwrap(); - let result = extract_rename_all(&item.attrs); - assert_eq!(result.as_deref(), Some("snake_case")); - } - - /// Test edge case: rename_all with extra whitespace (manual parsing should handle) - #[test] - fn test_extract_rename_all_with_whitespace() { - // Note: syn normalizes whitespace in parsed tokens, so this tests the robust parsing - let attrs = get_struct_attrs(r#"rename_all = "PascalCase""#); - let result = extract_rename_all(&attrs); - assert_eq!(result.as_deref(), Some("PascalCase")); - } - - /// Test edge case: rename at various positions - #[test] - fn test_extract_field_rename_at_end() { - let attrs = get_field_attrs(r#"skip_serializing_if = "is_none", rename = "lastField""#); - let result = extract_field_rename(&attrs); - assert_eq!(result.as_deref(), Some("lastField")); - } - - /// Test extract_default when it appears with other attrs - #[test] - fn test_extract_default_among_other_attrs() { - let attrs = - get_field_attrs(r#"skip_serializing_if = "is_none", default, rename = "field""#); - let result = extract_default(&attrs); - assert_eq!(result, Some(None)); - } - - /// Test extract_skip - basic functionality - #[test] - fn test_extract_skip_basic() { - let attrs = get_field_attrs(r"skip"); - let result = extract_skip(&attrs); - assert!(result); - } - - /// Test extract_skip does not trigger for skip_serializing_if - #[test] - fn test_extract_skip_not_skip_serializing_if() { - let attrs = get_field_attrs(r#"skip_serializing_if = "Option::is_none""#); - let result = extract_skip(&attrs); - assert!(!result); - } - - /// Test extract_skip does not trigger for skip_deserializing - #[test] - fn test_extract_skip_not_skip_deserializing() { - let attrs = get_field_attrs(r"skip_deserializing"); - let result = extract_skip(&attrs); - assert!(!result); - } - - /// Test extract_skip with combined attrs - #[test] - fn test_extract_skip_with_other_attrs() { - let attrs = get_field_attrs(r"skip, default"); - let result = extract_skip(&attrs); - assert!(result); - } - - /// Test extract_default function with path containing colons - #[test] - fn test_extract_default_with_path() { - let attrs = get_field_attrs(r#"default = "Default::default""#); - let result = extract_default(&attrs); - assert_eq!(result, Some(Some("Default::default".to_string()))); - } - - /// Test extract_skip_serializing_if with complex path - #[test] - fn test_extract_skip_serializing_if_complex_path() { - let attrs = get_field_attrs(r#"skip_serializing_if = "Vec::is_empty""#); - let result = extract_skip_serializing_if(&attrs); - assert!(result); - } - - /// Test extract_rename_all with all supported formats - #[rstest] - #[case("camelCase")] - #[case("snake_case")] - #[case("kebab-case")] - #[case("PascalCase")] - #[case("lowercase")] - #[case("UPPERCASE")] - #[case("SCREAMING_SNAKE_CASE")] - #[case("SCREAMING-KEBAB-CASE")] - fn test_extract_rename_all_all_formats(#[case] format: &str) { - let attrs = get_struct_attrs(&format!(r#"rename_all = "{format}""#)); - let result = extract_rename_all(&attrs); - assert_eq!(result.as_deref(), Some(format)); - } - - /// Test non-serde attribute doesn't affect extraction - #[test] - fn test_mixed_attributes() { - let item: syn::ItemStruct = syn::parse_str( - r#" - #[derive(Debug, Clone)] - #[serde(rename_all = "camelCase")] - #[doc = "Some documentation"] - struct Foo; - "#, - ) - .unwrap(); - let result = extract_rename_all(&item.attrs); - assert_eq!(result.as_deref(), Some("camelCase")); - } - - /// Test field with multiple serde attributes - #[test] - fn test_field_multiple_serde_attrs() { - let item: syn::ItemStruct = syn::parse_str( - r#" - struct Foo { - #[serde(default)] - #[serde(rename = "customName")] - field: i32 - } - "#, - ) - .unwrap(); - if let syn::Fields::Named(fields) = &item.fields { - let attrs = &fields.named.first().unwrap().attrs; - let rename = extract_field_rename(attrs); - let default = extract_default(attrs); - assert_eq!(rename.as_deref(), Some("customName")); - assert_eq!(default, Some(None)); - } - } - - /// Test extract_rename_all with programmatic tokens - #[test] - fn test_extract_rename_all_programmatic() { - let tokens = quote!(rename_all = "camelCase"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - assert_eq!(result.as_deref(), Some("camelCase")); - } - - /// Test extract_rename_all with invalid value (not a string) - #[test] - fn test_extract_rename_all_invalid_value() { - let tokens = quote!(rename_all = camelCase); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - // parse_nested_meta won't find a string literal - assert!(result.is_none()); - } - - /// Test extract_rename_all with missing equals sign - #[test] - fn test_extract_rename_all_no_equals() { - let tokens = quote!(rename_all "camelCase"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - assert!(result.is_none()); - } - - /// Test extract_field_rename with programmatic tokens - #[test] - fn test_extract_field_rename_programmatic() { - let tokens = quote!(rename = "customField"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_field_rename(&[attr]); - assert_eq!(result.as_deref(), Some("customField")); - } - - /// Test extract_default standalone with programmatic tokens - #[test] - fn test_extract_default_programmatic() { - let tokens = quote!(default); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_default(&[attr]); - assert_eq!(result, Some(None)); - } - - /// Test extract_default with function via programmatic tokens - #[test] - fn test_extract_default_with_fn_programmatic() { - let tokens = quote!(default = "my_fn"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_default(&[attr]); - assert_eq!(result, Some(Some("my_fn".to_string()))); - } - - /// Test extract_skip_serializing_if with programmatic tokens - #[test] - fn test_extract_skip_serializing_if_programmatic() { - let tokens = quote!(skip_serializing_if = "is_none"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_skip_serializing_if(&[attr]); - assert!(result); - } - - /// Test extract_skip via programmatic tokens - #[test] - fn test_extract_skip_programmatic() { - let tokens = quote!(skip); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_skip(&[attr]); - assert!(result); - } - - /// Test that rename_all is not confused with rename - #[test] - fn test_rename_all_not_rename() { - let tokens = quote!(rename_all = "camelCase"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_field_rename(&[attr]); - assert_eq!(result, None); - } - - /// Test multiple items in serde attribute - #[test] - fn test_multiple_items_programmatic() { - let tokens = quote!(default, rename = "myField", skip_serializing_if = "is_none"); - let attr = create_attr_with_raw_tokens(tokens); - - let rename_result = extract_field_rename(std::slice::from_ref(&attr)); - let default_result = extract_default(std::slice::from_ref(&attr)); - let skip_if_result = extract_skip_serializing_if(std::slice::from_ref(&attr)); - - assert_eq!(rename_result.as_deref(), Some("myField")); - assert_eq!(default_result, Some(None)); - assert!(skip_if_result); - } - - /// Test extract_rename_all fallback parsing - #[test] - fn test_extract_rename_all_fallback_manual_parsing() { - let tokens = quote!(rename_all = "kebab-case"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - assert_eq!(result.as_deref(), Some("kebab-case")); - } - - /// Test extract_rename_all with complex attribute that forces fallback - #[test] - fn test_extract_rename_all_complex_attribute_fallback() { - let tokens = quote!(default, rename_all = "SCREAMING_SNAKE_CASE", skip); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - assert_eq!(result.as_deref(), Some("SCREAMING_SNAKE_CASE")); - } - - /// Test extract_rename_all when value is not a string literal - #[test] - fn test_extract_rename_all_no_quote_start() { - let tokens = quote!(rename_all = snake_case); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - assert!(result.is_none()); - } - - /// Test extract_rename_all with unclosed quote - #[test] - fn test_extract_rename_all_unclosed_quote() { - let tokens = quote!(rename_all = "camelCase"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - assert_eq!(result.as_deref(), Some("camelCase")); - } - - /// Test extract_rename_all with empty string value - #[test] - fn test_extract_rename_all_empty_string() { - let tokens = quote!(rename_all = ""); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - assert_eq!(result.as_deref(), Some("")); - } - - /// Test extract_rename_all with QUALIFIED PATH to force fallback - #[test] - fn test_extract_rename_all_qualified_path_forces_fallback() { - let tokens = quote!(serde_with::rename_all = "camelCase"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - assert_eq!(result.as_deref(), Some("camelCase")); - } - - /// Test extract_rename_all with another qualified path variation - #[test] - fn test_extract_rename_all_module_qualified_forces_fallback() { - let tokens = quote!(my_module::rename_all = "snake_case"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - assert_eq!(result.as_deref(), Some("snake_case")); - } - - /// Test extract_rename_all with deeply qualified path - #[test] - fn test_extract_rename_all_deeply_qualified_forces_fallback() { - let tokens = quote!(a::b::rename_all = "PascalCase"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - assert_eq!(result.as_deref(), Some("PascalCase")); - } - - /// CRITICAL TEST: This test MUST hit fallback path - #[test] - fn test_extract_rename_all_raw_tokens_force_fallback() { - let tokens: TokenStream = "__rename_all_prefix::rename_all = \"lowercase\"" - .parse() - .unwrap(); - let attr = create_attr_with_raw_tokens(tokens); - - if let syn::Meta::List(list) = &attr.meta { - let token_str = list.tokens.to_string(); - assert!( - token_str.contains("rename_all"), - "Token string should contain rename_all: {token_str}" - ); - } - - let result = extract_rename_all(&[attr]); - assert_eq!( - result.as_deref(), - Some("lowercase"), - "Fallback parsing must extract the value" - ); - } - - /// Another critical test with different qualified path format - #[test] - fn test_extract_rename_all_crate_qualified_forces_fallback() { - let tokens: TokenStream = "crate::rename_all = \"UPPERCASE\"".parse().unwrap(); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - assert_eq!(result.as_deref(), Some("UPPERCASE")); - } - - /// Test with self:: prefix - #[test] - fn test_extract_rename_all_self_qualified_forces_fallback() { - let tokens: TokenStream = "self::rename_all = \"kebab-case\"".parse().unwrap(); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - assert_eq!(result.as_deref(), Some("kebab-case")); - } - - // ================================================================= - // FALLBACK PATH TESTS (Lines 173, 258-265, 573, 583-590, 626) - // ================================================================= - - /// Test extract_field_rename fallback path - Line 173 - /// Tests the word boundary check when "rename" appears with other attributes - /// This triggers the manual token parsing fallback when parse_nested_meta - /// doesn't extract the value in expected format - #[test] - fn test_extract_field_rename_fallback_word_boundary() { - // Create attribute with qualified path to force fallback - let tokens: TokenStream = "my_module::rename = \"value\"".parse().unwrap(); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_field_rename(&[attr]); - assert_eq!(result.as_deref(), Some("value")); - } - - /// Test extract_field_rename fallback - complex combined attributes - /// Line 173: Tests the edge case of word boundary checking - #[test] - fn test_extract_field_rename_fallback_complex_attr() { - // Qualified path forces parse_nested_meta to not find "rename" - let tokens: TokenStream = "crate::other::rename = \"custom_field\", default" - .parse() - .unwrap(); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_field_rename(&[attr]); - assert_eq!(result.as_deref(), Some("custom_field")); - } - - /// Test extract_field_rename - ensure rename_all is not matched as rename - /// Test the word boundary logic - #[test] - fn test_extract_field_rename_fallback_avoids_rename_all() { - let tokens: TokenStream = "some::rename_all = \"camelCase\"".parse().unwrap(); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_field_rename(&[attr]); - // Should NOT match rename_all as rename - assert_eq!(result, None); - } - - /// Test extract_flatten fallback path - Lines 258-265 - /// Forces manual token parsing by using qualified path - #[test] - fn test_extract_flatten_fallback_path() { - let tokens: TokenStream = "my_module::flatten".parse().unwrap(); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_flatten(&[attr]); - assert!(result, "Fallback should find 'flatten' in token string"); - } - - /// Test extract_flatten fallback with complex attributes - /// Lines 258-263: Tests word boundary checking in fallback - #[test] - fn test_extract_flatten_fallback_complex() { - let tokens: TokenStream = "crate::flatten, default = \"my_fn\"".parse().unwrap(); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_flatten(&[attr]); - assert!(result, "Fallback should detect flatten with other attrs"); - } - - /// Test extract_flatten fallback with flatten at different positions - /// Line 265: Tests the return true path in fallback - #[test] - fn test_extract_flatten_fallback_at_end() { - let tokens: TokenStream = "default, some::flatten".parse().unwrap(); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_flatten(&[attr]); - assert!(result); - } - - /// Test extract_flatten fallback doesn't match partial words - #[test] - fn test_extract_flatten_fallback_no_partial_match() { - // "flattened" should not match "flatten" - let tokens: TokenStream = "flattened".parse().unwrap(); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_flatten(&[attr]); - assert!(!result, "Should not match 'flattened' as 'flatten'"); - } - // ================================================================= - // MULTIPART FALLBACK TESTS (form_data / try_from_multipart) - // ================================================================= - - /// Test extract_field_rename falls back to #[form_data(field_name = "...")] - #[test] - fn test_extract_field_rename_form_data_fallback() { - let struct_src = r#"struct Foo { #[form_data(field_name = "my_file")] field: i32 }"#; - let item: syn::ItemStruct = syn::parse_str(struct_src).unwrap(); - if let syn::Fields::Named(fields) = &item.fields { - let field = fields.named.first().unwrap(); - let result = extract_field_rename(&field.attrs); - assert_eq!(result.as_deref(), Some("my_file")); - } - } - - /// Test serde rename takes priority over form_data field_name - #[test] - fn test_extract_field_rename_serde_over_form_data() { - let struct_src = r#"struct Foo { #[serde(rename = "serde_name")] #[form_data(field_name = "form_name")] field: i32 }"#; - let item: syn::ItemStruct = syn::parse_str(struct_src).unwrap(); - if let syn::Fields::Named(fields) = &item.fields { - let field = fields.named.first().unwrap(); - let result = extract_field_rename(&field.attrs); - assert_eq!(result.as_deref(), Some("serde_name")); - } - } - - /// Test extract_field_rename with form_data but no field_name key - #[test] - fn test_extract_field_rename_form_data_no_field_name() { - let struct_src = r#"struct Foo { #[form_data(limit = "10MiB")] field: i32 }"#; - let item: syn::ItemStruct = syn::parse_str(struct_src).unwrap(); - if let syn::Fields::Named(fields) = &item.fields { - let field = fields.named.first().unwrap(); - let result = extract_field_rename(&field.attrs); - assert_eq!(result, None); - } - } - - /// Test extract_rename_all falls back to #[try_from_multipart(rename_all = "...")] - #[test] - fn test_extract_rename_all_try_from_multipart_fallback() { - let item: syn::ItemStruct = - syn::parse_str(r#"#[try_from_multipart(rename_all = "camelCase")] struct Foo;"#) - .unwrap(); - let result = extract_rename_all(&item.attrs); - assert_eq!(result.as_deref(), Some("camelCase")); - } - - /// Test serde rename_all takes priority over try_from_multipart rename_all - #[test] - fn test_extract_rename_all_serde_over_try_from_multipart() { - let item: syn::ItemStruct = syn::parse_str(r#"#[serde(rename_all = "snake_case")] #[try_from_multipart(rename_all = "camelCase")] struct Foo;"#).unwrap(); - let result = extract_rename_all(&item.attrs); - assert_eq!(result.as_deref(), Some("snake_case")); - } - - /// Test extract_rename_all with try_from_multipart but no rename_all key - #[test] - fn test_extract_rename_all_try_from_multipart_no_rename_all() { - let item: syn::ItemStruct = - syn::parse_str(r"#[try_from_multipart(strict)] struct Foo;").unwrap(); - let result = extract_rename_all(&item.attrs); - assert_eq!(result, None); - } - } - - // Tests for enum representation extraction (tag, content, untagged) - mod enum_repr_tests { - use super::*; - - fn get_enum_attrs(serde_content: &str) -> Vec { - let src = format!(r"#[serde({serde_content})] enum Foo {{ A, B }}"); - let item: syn::ItemEnum = syn::parse_str(&src).unwrap(); - item.attrs - } - - // extract_tag tests - #[rstest] - #[case(r#"tag = "type""#, Some("type"))] - #[case(r#"tag = "kind""#, Some("kind"))] - #[case(r#"tag = "variant""#, Some("variant"))] - #[case(r#"tag = "type", content = "data""#, Some("type"))] - #[case(r#"rename_all = "camelCase""#, None)] - #[case(r"untagged", None)] - #[case(r"default", None)] - fn test_extract_tag(#[case] serde_content: &str, #[case] expected: Option<&str>) { - let attrs = get_enum_attrs(serde_content); - let result = extract_tag(&attrs); - assert_eq!(result.as_deref(), expected, "Failed for: {serde_content}"); - } - - // extract_content tests - #[rstest] - #[case(r#"content = "data""#, Some("data"))] - #[case(r#"content = "payload""#, Some("payload"))] - #[case(r#"tag = "type", content = "data""#, Some("data"))] - #[case(r#"tag = "type""#, None)] - #[case(r"untagged", None)] - #[case(r#"rename_all = "camelCase""#, None)] - fn test_extract_content(#[case] serde_content: &str, #[case] expected: Option<&str>) { - let attrs = get_enum_attrs(serde_content); - let result = extract_content(&attrs); - assert_eq!(result.as_deref(), expected, "Failed for: {serde_content}"); - } - - // extract_untagged tests - #[rstest] - #[case(r"untagged", true)] - #[case(r#"untagged, rename_all = "camelCase""#, true)] - #[case(r#"rename_all = "camelCase", untagged"#, true)] - #[case(r#"tag = "type""#, false)] - #[case(r#"rename_all = "camelCase""#, false)] - #[case(r"default", false)] - fn test_extract_untagged(#[case] serde_content: &str, #[case] expected: bool) { - let attrs = get_enum_attrs(serde_content); - let result = extract_untagged(&attrs); - assert_eq!(result, expected, "Failed for: {serde_content}"); - } - - // extract_enum_repr comprehensive tests - #[test] - fn test_extract_enum_repr_externally_tagged() { - // No serde tag attributes - default is externally tagged - let attrs = get_enum_attrs(r#"rename_all = "camelCase""#); - let repr = extract_enum_repr(&attrs); - assert_eq!(repr, SerdeEnumRepr::ExternallyTagged); - } - - #[test] - fn test_extract_enum_repr_internally_tagged() { - let attrs = get_enum_attrs(r#"tag = "type""#); - let repr = extract_enum_repr(&attrs); - assert_eq!( - repr, - SerdeEnumRepr::InternallyTagged { - tag: "type".to_string() - } - ); - } - - #[test] - fn test_extract_enum_repr_internally_tagged_custom_name() { - let attrs = get_enum_attrs(r#"tag = "kind""#); - let repr = extract_enum_repr(&attrs); - assert_eq!( - repr, - SerdeEnumRepr::InternallyTagged { - tag: "kind".to_string() - } - ); - } - - #[test] - fn test_extract_enum_repr_adjacently_tagged() { - let attrs = get_enum_attrs(r#"tag = "type", content = "data""#); - let repr = extract_enum_repr(&attrs); - assert_eq!( - repr, - SerdeEnumRepr::AdjacentlyTagged { - tag: "type".to_string(), - content: "data".to_string() - } - ); - } - - #[test] - fn test_extract_enum_repr_adjacently_tagged_custom_names() { - let attrs = get_enum_attrs(r#"tag = "kind", content = "payload""#); - let repr = extract_enum_repr(&attrs); - assert_eq!( - repr, - SerdeEnumRepr::AdjacentlyTagged { - tag: "kind".to_string(), - content: "payload".to_string() - } - ); - } - - #[test] - fn test_extract_enum_repr_untagged() { - let attrs = get_enum_attrs(r"untagged"); - let repr = extract_enum_repr(&attrs); - assert_eq!(repr, SerdeEnumRepr::Untagged); - } - - #[test] - fn test_extract_enum_repr_untagged_with_other_attrs() { - let attrs = get_enum_attrs(r#"untagged, rename_all = "camelCase""#); - let repr = extract_enum_repr(&attrs); - assert_eq!(repr, SerdeEnumRepr::Untagged); - } - - #[test] - fn test_extract_enum_repr_no_serde_attrs() { - let item: syn::ItemEnum = syn::parse_str("enum Foo { A, B }").unwrap(); - let repr = extract_enum_repr(&item.attrs); - assert_eq!(repr, SerdeEnumRepr::ExternallyTagged); - } - - // Test that content without tag is still externally tagged (content alone is meaningless) - #[test] - fn test_extract_enum_repr_content_without_tag() { - let attrs = get_enum_attrs(r#"content = "data""#); - let repr = extract_enum_repr(&attrs); - // Content without tag should be externally tagged (content is ignored) - assert_eq!(repr, SerdeEnumRepr::ExternallyTagged); - } - - // ================================================================= - // FALLBACK PATH TESTS FOR TAG/CONTENT (Lines 573, 583-590, 626) - // ================================================================= - - use proc_macro2::{Span, TokenStream}; - - /// Helper to create a serde attribute with raw tokens - fn create_enum_attr_with_raw_tokens(tokens: TokenStream) -> syn::Attribute { - syn::Attribute { - pound_token: syn::token::Pound::default(), - style: syn::AttrStyle::Outer, - bracket_token: syn::token::Bracket::default(), - meta: syn::Meta::List(syn::MetaList { - path: syn::Path::from(syn::Ident::new("serde", Span::call_site())), - delimiter: syn::MacroDelimiter::Paren(syn::token::Paren::default()), - tokens, - }), - } - } - - /// Test extract_tag fallback path - Lines 573, 583-590 - /// Forces manual token parsing by using qualified path - #[test] - fn test_extract_tag_fallback_path() { - let tokens: TokenStream = "my_module::tag = \"type\"".parse().unwrap(); - let attr = create_enum_attr_with_raw_tokens(tokens); - let result = extract_tag(&[attr]); - assert_eq!( - result.as_deref(), - Some("type"), - "Fallback should extract tag value" - ); - } - - /// Test extract_tag fallback with complex attributes - /// Lines 583-590: Tests the value extraction in fallback - #[test] - fn test_extract_tag_fallback_complex() { - let tokens: TokenStream = "crate::tag = \"kind\", rename_all = \"camelCase\"" - .parse() - .unwrap(); - let attr = create_enum_attr_with_raw_tokens(tokens); - let result = extract_tag(&[attr]); - assert_eq!(result.as_deref(), Some("kind")); - } - - /// Test extract_tag fallback doesn't match "untagged" - /// Line 581: before_char != 'n' check - #[test] - fn test_extract_tag_fallback_avoids_untagged() { - // "untagged" contains "tag" but should not be matched as tag = "..." - let tokens: TokenStream = "untagged".parse().unwrap(); - let attr = create_enum_attr_with_raw_tokens(tokens); - let result = extract_tag(&[attr]); - assert_eq!(result, None, "Should not extract tag from 'untagged'"); - } - - /// Test extract_tag fallback with tag after other attributes - #[test] - fn test_extract_tag_fallback_at_end() { - let tokens: TokenStream = "default, some_module::tag = \"variant\"".parse().unwrap(); - let attr = create_enum_attr_with_raw_tokens(tokens); - let result = extract_tag(&[attr]); - assert_eq!(result.as_deref(), Some("variant")); - } - - /// Test extract_content fallback path - Line 626 - /// Forces manual token parsing by using qualified path - #[test] - fn test_extract_content_fallback_path() { - let tokens: TokenStream = "my_module::content = \"data\"".parse().unwrap(); - let attr = create_enum_attr_with_raw_tokens(tokens); - let result = extract_content(&[attr]); - assert_eq!( - result.as_deref(), - Some("data"), - "Fallback should extract content value" - ); - } - - /// Test extract_content fallback with complex attributes - /// Line 626+: Tests the fallback token parsing branch - #[test] - fn test_extract_content_fallback_complex() { - let tokens: TokenStream = "crate::tag = \"type\", other::content = \"payload\"" - .parse() - .unwrap(); - let attr = create_enum_attr_with_raw_tokens(tokens); - let result = extract_content(&[attr]); - assert_eq!(result.as_deref(), Some("payload")); - } - - /// Test extract_content fallback with content at different position - #[test] - fn test_extract_content_fallback_at_start() { - let tokens: TokenStream = "some::content = \"body\", tag = \"kind\"".parse().unwrap(); - let attr = create_enum_attr_with_raw_tokens(tokens); - let result = extract_content(&[attr]); - assert_eq!(result.as_deref(), Some("body")); - } - - /// Test adjacently tagged using fallback paths for both tag and content - #[test] - fn test_extract_enum_repr_adjacently_tagged_fallback() { - let tokens: TokenStream = "mod1::tag = \"type\", mod2::content = \"data\"" - .parse() - .unwrap(); - let attr = create_enum_attr_with_raw_tokens(tokens); - let repr = extract_enum_repr(&[attr]); - assert_eq!( - repr, - SerdeEnumRepr::AdjacentlyTagged { - tag: "type".to_string(), - content: "data".to_string() - } - ); - } - - /// Test internally tagged using fallback path - #[test] - fn test_extract_enum_repr_internally_tagged_fallback() { - let tokens: TokenStream = "qualified::tag = \"discriminator\"".parse().unwrap(); - let attr = create_enum_attr_with_raw_tokens(tokens); - let repr = extract_enum_repr(&[attr]); - assert_eq!( - repr, - SerdeEnumRepr::InternallyTagged { - tag: "discriminator".to_string() - } - ); - } - - /// Helper to create a path-only serde attribute (#[serde] without parentheses) - /// This format causes require_list() to fail (returns Err) - fn create_path_only_serde_attr() -> syn::Attribute { - syn::Attribute { - pound_token: syn::token::Pound::default(), - style: syn::AttrStyle::Outer, - bracket_token: syn::token::Bracket::default(), - meta: syn::Meta::Path(syn::Path::from(syn::Ident::new("serde", Span::call_site()))), - } - } - - /// Test extract_tag with non-list serde attribute - /// When require_list() fails, extract_tag should continue to next attribute - #[test] - fn test_extract_tag_non_list_attr_continues() { - // First attr is path-only (#[serde]), second has the actual tag - let path_attr = create_path_only_serde_attr(); - let list_attr = { - let src = r#"#[serde(tag = "type")] enum Foo { A }"#; - let item: syn::ItemEnum = syn::parse_str(src).unwrap(); - item.attrs.into_iter().next().unwrap() - }; - - // extract_tag should skip the path-only attr and find tag in second attr - let result = extract_tag(&[path_attr, list_attr]); - assert_eq!(result.as_deref(), Some("type")); - } - - /// Test extract_tag with only non-list serde attribute returns None - #[test] - fn test_extract_tag_only_non_list_attr_returns_none() { - let path_attr = create_path_only_serde_attr(); - let result = extract_tag(&[path_attr]); - assert_eq!(result, None); - } - - /// Test extract_content with non-list serde attribute - /// When require_list() fails, extract_content should continue to next attribute - #[test] - fn test_extract_content_non_list_attr_continues() { - // First attr is path-only (#[serde]), second has the actual content - let path_attr = create_path_only_serde_attr(); - let list_attr = { - let src = r#"#[serde(content = "data")] enum Foo { A }"#; - let item: syn::ItemEnum = syn::parse_str(src).unwrap(); - item.attrs.into_iter().next().unwrap() - }; - - // extract_content should skip the path-only attr and find content in second attr - let result = extract_content(&[path_attr, list_attr]); - assert_eq!(result.as_deref(), Some("data")); - } - - /// Test extract_content with only non-list serde attribute returns None - #[test] - fn test_extract_content_only_non_list_attr_returns_none() { - let path_attr = create_path_only_serde_attr(); - let result = extract_content(&[path_attr]); - assert_eq!(result, None); - } - } -} +//! Serde attribute extraction utilities for OpenAPI schema generation. + +mod common; +mod enum_repr; +mod extract; +mod fallback; +mod rename_case; + +pub use common::{ + capitalize_first, extract_doc_comment, extract_schema_name_from_entity, + extract_schema_ref_override, extract_transparent, strip_raw_prefix_owned, +}; +pub use enum_repr::{SerdeEnumRepr, extract_enum_repr}; +pub use extract::{ + extract_default, extract_field_rename, extract_flatten, extract_rename_all, extract_skip, + extract_skip_serializing_if, +}; +pub use rename_case::rename_field; diff --git a/crates/vespera_macro/src/parser/schema/serde_attrs/common.rs b/crates/vespera_macro/src/parser/schema/serde_attrs/common.rs new file mode 100644 index 00000000..caa6f8e5 --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/serde_attrs/common.rs @@ -0,0 +1,237 @@ +//! Serde attribute extraction utilities for `OpenAPI` schema generation. +//! +//! This module provides functions to extract serde attributes from Rust types +//! to properly generate `OpenAPI` schemas that respect serialization rules. + +/// Extract doc comments from attributes. +/// Returns concatenated doc comment string or None if no doc comments. +pub fn extract_doc_comment(attrs: &[syn::Attribute]) -> Option { + let mut doc_lines = Vec::new(); + + for attr in attrs { + if attr.path().is_ident("doc") + && let syn::Meta::NameValue(meta_nv) = &attr.meta + && let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(lit_str), + .. + }) = &meta_nv.value + { + let line = lit_str.value(); + // Strip `" / "` or `"/ "` prefixes that can appear when doc-comment + // markers leak through TokenStream → string → parse roundtrips, + // then trim any remaining whitespace. + let trimmed = line + .strip_prefix(" / ") + .or_else(|| line.strip_prefix("/ ")) + .unwrap_or(&line) + .trim(); + doc_lines.push(trimmed.to_string()); + } + } + + if doc_lines.is_empty() { + None + } else { + Some(doc_lines.join("\n")) + } +} + +/// Strips the `r#` prefix from raw identifiers, returning an owned `String`. +/// For the 99% case (no `r#` prefix), returns the input directly with zero extra allocation. +#[allow(clippy::option_if_let_else)] // clippy suggestion doesn't compile: borrow-move conflict +pub fn strip_raw_prefix_owned(ident: String) -> String { + if let Some(stripped) = ident.strip_prefix("r#") { + stripped.to_string() + } else { + ident + } +} + +pub use crate::schema_macro::type_utils::capitalize_first; + +/// Extract a Schema name from a `SeaORM` Entity type path. +/// +/// Converts paths like: +/// - `super::user::Entity` -> "User" +/// - `crate::models::memo::Entity` -> "Memo" +/// +/// The schema name is derived from the module containing Entity, +/// converted to `PascalCase` (first letter uppercase). +pub fn extract_schema_name_from_entity(ty: &syn::Type) -> Option { + match ty { + syn::Type::Path(type_path) => { + let segments: Vec<_> = type_path.path.segments.iter().collect(); + + // Need at least 2 segments: module::Entity + if segments.len() < 2 { + return None; + } + + // Check if last segment is "Entity" + let last = segments.last()?; + if last.ident != "Entity" { + return None; + } + + // Get the second-to-last segment (module name) + let module_segment = segments.get(segments.len() - 2)?; + let module_name = module_segment.ident.to_string(); + + // Convert to PascalCase (capitalize first letter) + // Rust identifiers are guaranteed non-empty, so chars().next() always returns Some + let schema_name = capitalize_first(&module_name); + + Some(schema_name) + } + _ => None, + } +} + +/// Extract whether `#[serde(transparent)]` is present on a struct. +pub fn extract_transparent(attrs: &[syn::Attribute]) -> bool { + attrs.iter().any(|attr| { + if !attr.path().is_ident("serde") { + return false; + } + + let mut is_transparent = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("transparent") { + is_transparent = true; + } + Ok(()) + }); + is_transparent + }) +} + +/// Extract `#[schema(ref = "Name", nullable)]` override from a struct. +pub fn extract_schema_ref_override(attrs: &[syn::Attribute]) -> Option<(String, bool)> { + attrs.iter().find_map(|attr| { + if !attr.path().is_ident("schema") { + return None; + } + + let mut ref_name = None; + let mut nullable = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("ref") { + let value = meta.value()?; + let lit: syn::LitStr = value.parse()?; + ref_name = Some(lit.value()); + } else if meta.path.is_ident("nullable") { + nullable = true; + } + Ok(()) + }); + + ref_name.map(|name| (name, nullable)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + // Tests for extract_doc_comment function + #[test] + fn test_extract_doc_comment_single_line() { + let attrs: Vec = syn::parse_quote! { + #[doc = " This is a doc comment"] + }; + let result = extract_doc_comment(&attrs); + assert_eq!(result, Some("This is a doc comment".to_string())); + } + + #[test] + fn test_extract_doc_comment_multi_line() { + let attrs: Vec = syn::parse_quote! { + #[doc = " First line"] + #[doc = " Second line"] + #[doc = " Third line"] + }; + let result = extract_doc_comment(&attrs); + assert_eq!( + result, + Some("First line\nSecond line\nThird line".to_string()) + ); + } + + #[test] + fn test_extract_doc_comment_no_leading_space() { + let attrs: Vec = syn::parse_quote! { + #[doc = "No leading space"] + }; + let result = extract_doc_comment(&attrs); + assert_eq!(result, Some("No leading space".to_string())); + } + + #[test] + fn test_extract_doc_comment_empty() { + let attrs: Vec = vec![]; + let result = extract_doc_comment(&attrs); + assert_eq!(result, None); + } + + #[test] + fn test_extract_doc_comment_with_non_doc_attrs() { + let attrs: Vec = syn::parse_quote! { + #[derive(Debug)] + #[doc = " The doc comment"] + #[serde(rename = "test")] + }; + let result = extract_doc_comment(&attrs); + assert_eq!(result, Some("The doc comment".to_string())); + } + + // Tests for extract_schema_name_from_entity function + #[test] + fn test_extract_schema_name_from_entity_super_path() { + let ty: syn::Type = syn::parse_str("super::user::Entity").unwrap(); + let result = extract_schema_name_from_entity(&ty); + assert_eq!(result, Some("User".to_string())); + } + + #[test] + fn test_extract_schema_name_from_entity_crate_path() { + let ty: syn::Type = syn::parse_str("crate::models::memo::Entity").unwrap(); + let result = extract_schema_name_from_entity(&ty); + assert_eq!(result, Some("Memo".to_string())); + } + + #[test] + fn test_extract_schema_name_from_entity_not_entity() { + let ty: syn::Type = syn::parse_str("crate::models::user::Model").unwrap(); + let result = extract_schema_name_from_entity(&ty); + assert_eq!(result, None); + } + + #[test] + fn test_extract_schema_name_from_entity_single_segment() { + let ty: syn::Type = syn::parse_str("Entity").unwrap(); + let result = extract_schema_name_from_entity(&ty); + assert_eq!(result, None); + } + + #[test] + fn test_extract_schema_name_from_entity_non_path_type() { + let ty: syn::Type = syn::parse_str("&str").unwrap(); + let result = extract_schema_name_from_entity(&ty); + assert_eq!(result, None); + } + + #[test] + fn test_extract_schema_name_from_entity_empty_module_name() { + // Tests the branch where module name has no characters (edge case) + let ty: syn::Type = syn::parse_str("super::some_module::Entity").unwrap(); + let result = extract_schema_name_from_entity(&ty); + assert_eq!(result, Some("Some_module".to_string())); + } + /// Test strip_raw_prefix_owned function + #[test] + fn test_strip_raw_prefix_owned() { + assert_eq!(strip_raw_prefix_owned("r#type".to_string()), "type"); + assert_eq!(strip_raw_prefix_owned("r#match".to_string()), "match"); + assert_eq!(strip_raw_prefix_owned("normal".to_string()), "normal"); + assert_eq!(strip_raw_prefix_owned("r#".to_string()), ""); + } +} diff --git a/crates/vespera_macro/src/parser/schema/serde_attrs/enum_repr.rs b/crates/vespera_macro/src/parser/schema/serde_attrs/enum_repr.rs new file mode 100644 index 00000000..85d71803 --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/serde_attrs/enum_repr.rs @@ -0,0 +1,512 @@ +/// Serde enum representation types +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SerdeEnumRepr { + /// Default externally tagged: `{"VariantName": {...}}` + ExternallyTagged, + /// Internally tagged: `{"type": "VariantName", ...fields...}` + /// Only valid for struct and unit variants + InternallyTagged { tag: String }, + /// Adjacently tagged: `{"type": "VariantName", "data": {...}}` + AdjacentlyTagged { tag: String, content: String }, + /// Untagged: `{...fields...}` (no tag, first matching variant wins) + Untagged, +} + +/// Extract serde enum representation from attributes. +/// +/// Detects the enum tagging strategy from serde attributes: +/// - `#[serde(tag = "type")]` → `InternallyTagged` +/// - `#[serde(tag = "type", content = "data")]` → `AdjacentlyTagged` +/// - `#[serde(untagged)]` → Untagged +/// - No relevant attributes → `ExternallyTagged` (default) +pub fn extract_enum_repr(attrs: &[syn::Attribute]) -> SerdeEnumRepr { + let tag = extract_tag(attrs); + let content = extract_content(attrs); + let untagged = extract_untagged(attrs); + + if untagged { + SerdeEnumRepr::Untagged + } else if let Some(tag_name) = tag { + if let Some(content_name) = content { + SerdeEnumRepr::AdjacentlyTagged { + tag: tag_name, + content: content_name, + } + } else { + SerdeEnumRepr::InternallyTagged { tag: tag_name } + } + } else { + SerdeEnumRepr::ExternallyTagged + } +} + +/// Extract tag attribute from serde container attributes +/// Returns the tag name if `#[serde(tag = "...")]` is present +pub fn extract_tag(attrs: &[syn::Attribute]) -> Option { + for attr in attrs { + if attr.path().is_ident("serde") { + let mut found_tag = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("tag") + && let Ok(value) = meta.value() + && let Ok(syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + })) = value.parse::() + { + found_tag = Some(s.value()); + } + Ok(()) + }); + if found_tag.is_some() { + return found_tag; + } + + // Fallback: manual token parsing + let Ok(tokens) = attr.meta.require_list() else { + continue; + }; + let token_str = tokens.tokens.to_string(); + + if let Some(start) = token_str.find("tag") { + // Ensure it's "tag" not "untagged" + let before = if start > 0 { &token_str[..start] } else { "" }; + let before_char = before.chars().last().unwrap_or(' '); + if before_char != 'n' { + // Not "untagged" + let remaining = &token_str[start + "tag".len()..]; + if let Some(equals_pos) = remaining.find('=') { + let value_part = remaining[equals_pos + 1..].trim(); + if let Some(quote_start) = value_part.find('"') { + let after_quote = &value_part[quote_start + 1..]; + if let Some(quote_end) = after_quote.find('"') { + let value = &after_quote[..quote_end]; + return Some(value.to_string()); + } + } + } + } + } + } + } + None +} + +/// Extract content attribute from serde container attributes +/// Returns the content name if `#[serde(content = "...")]` is present +pub fn extract_content(attrs: &[syn::Attribute]) -> Option { + for attr in attrs { + if attr.path().is_ident("serde") { + let mut found_content = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("content") + && let Ok(value) = meta.value() + && let Ok(syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + })) = value.parse::() + { + found_content = Some(s.value()); + } + Ok(()) + }); + if found_content.is_some() { + return found_content; + } + + // Fallback: manual token parsing + let Ok(tokens) = attr.meta.require_list() else { + continue; + }; + let token_str = tokens.tokens.to_string(); + + if let Some(start) = token_str.find("content") { + let remaining = &token_str[start + "content".len()..]; + if let Some(equals_pos) = remaining.find('=') { + let value_part = remaining[equals_pos + 1..].trim(); + if let Some(quote_start) = value_part.find('"') { + let after_quote = &value_part[quote_start + 1..]; + if let Some(quote_end) = after_quote.find('"') { + let value = &after_quote[..quote_end]; + return Some(value.to_string()); + } + } + } + } + } + } + None +} + +/// Extract untagged attribute from serde container attributes +/// Returns true if `#[serde(untagged)]` is present +pub fn extract_untagged(attrs: &[syn::Attribute]) -> bool { + for attr in attrs { + if attr.path().is_ident("serde") { + let mut found = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("untagged") { + found = true; + } + Ok(()) + }); + if found { + return true; + } + + // Fallback: manual token parsing + if let syn::Meta::List(meta_list) = &attr.meta { + let tokens = meta_list.tokens.to_string(); + if let Some(pos) = tokens.find("untagged") { + let before = if pos > 0 { &tokens[..pos] } else { "" }; + let after = &tokens[pos + "untagged".len()..]; + let before_char = before.chars().last().unwrap_or(' '); + let after_char = after.chars().next().unwrap_or(' '); + if (before_char == ' ' || before_char == ',' || before_char == '(') + && (after_char == ' ' || after_char == ',' || after_char == ')') + { + return true; + } + } + } + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + fn get_enum_attrs(serde_content: &str) -> Vec { + let src = format!(r"#[serde({serde_content})] enum Foo {{ A, B }}"); + let item: syn::ItemEnum = syn::parse_str(&src).unwrap(); + item.attrs + } + + // extract_tag tests + #[rstest] + #[case(r#"tag = "type""#, Some("type"))] + #[case(r#"tag = "kind""#, Some("kind"))] + #[case(r#"tag = "variant""#, Some("variant"))] + #[case(r#"tag = "type", content = "data""#, Some("type"))] + #[case(r#"rename_all = "camelCase""#, None)] + #[case(r"untagged", None)] + #[case(r"default", None)] + fn test_extract_tag(#[case] serde_content: &str, #[case] expected: Option<&str>) { + let attrs = get_enum_attrs(serde_content); + let result = extract_tag(&attrs); + assert_eq!(result.as_deref(), expected, "Failed for: {serde_content}"); + } + + // extract_content tests + #[rstest] + #[case(r#"content = "data""#, Some("data"))] + #[case(r#"content = "payload""#, Some("payload"))] + #[case(r#"tag = "type", content = "data""#, Some("data"))] + #[case(r#"tag = "type""#, None)] + #[case(r"untagged", None)] + #[case(r#"rename_all = "camelCase""#, None)] + fn test_extract_content(#[case] serde_content: &str, #[case] expected: Option<&str>) { + let attrs = get_enum_attrs(serde_content); + let result = extract_content(&attrs); + assert_eq!(result.as_deref(), expected, "Failed for: {serde_content}"); + } + + // extract_untagged tests + #[rstest] + #[case(r"untagged", true)] + #[case(r#"untagged, rename_all = "camelCase""#, true)] + #[case(r#"rename_all = "camelCase", untagged"#, true)] + #[case(r#"tag = "type""#, false)] + #[case(r#"rename_all = "camelCase""#, false)] + #[case(r"default", false)] + fn test_extract_untagged(#[case] serde_content: &str, #[case] expected: bool) { + let attrs = get_enum_attrs(serde_content); + let result = extract_untagged(&attrs); + assert_eq!(result, expected, "Failed for: {serde_content}"); + } + + // extract_enum_repr comprehensive tests + #[test] + fn test_extract_enum_repr_externally_tagged() { + // No serde tag attributes - default is externally tagged + let attrs = get_enum_attrs(r#"rename_all = "camelCase""#); + let repr = extract_enum_repr(&attrs); + assert_eq!(repr, SerdeEnumRepr::ExternallyTagged); + } + + #[test] + fn test_extract_enum_repr_internally_tagged() { + let attrs = get_enum_attrs(r#"tag = "type""#); + let repr = extract_enum_repr(&attrs); + assert_eq!( + repr, + SerdeEnumRepr::InternallyTagged { + tag: "type".to_string() + } + ); + } + + #[test] + fn test_extract_enum_repr_internally_tagged_custom_name() { + let attrs = get_enum_attrs(r#"tag = "kind""#); + let repr = extract_enum_repr(&attrs); + assert_eq!( + repr, + SerdeEnumRepr::InternallyTagged { + tag: "kind".to_string() + } + ); + } + + #[test] + fn test_extract_enum_repr_adjacently_tagged() { + let attrs = get_enum_attrs(r#"tag = "type", content = "data""#); + let repr = extract_enum_repr(&attrs); + assert_eq!( + repr, + SerdeEnumRepr::AdjacentlyTagged { + tag: "type".to_string(), + content: "data".to_string() + } + ); + } + + #[test] + fn test_extract_enum_repr_adjacently_tagged_custom_names() { + let attrs = get_enum_attrs(r#"tag = "kind", content = "payload""#); + let repr = extract_enum_repr(&attrs); + assert_eq!( + repr, + SerdeEnumRepr::AdjacentlyTagged { + tag: "kind".to_string(), + content: "payload".to_string() + } + ); + } + + #[test] + fn test_extract_enum_repr_untagged() { + let attrs = get_enum_attrs(r"untagged"); + let repr = extract_enum_repr(&attrs); + assert_eq!(repr, SerdeEnumRepr::Untagged); + } + + #[test] + fn test_extract_enum_repr_untagged_with_other_attrs() { + let attrs = get_enum_attrs(r#"untagged, rename_all = "camelCase""#); + let repr = extract_enum_repr(&attrs); + assert_eq!(repr, SerdeEnumRepr::Untagged); + } + + #[test] + fn test_extract_enum_repr_no_serde_attrs() { + let item: syn::ItemEnum = syn::parse_str("enum Foo { A, B }").unwrap(); + let repr = extract_enum_repr(&item.attrs); + assert_eq!(repr, SerdeEnumRepr::ExternallyTagged); + } + + // Test that content without tag is still externally tagged (content alone is meaningless) + #[test] + fn test_extract_enum_repr_content_without_tag() { + let attrs = get_enum_attrs(r#"content = "data""#); + let repr = extract_enum_repr(&attrs); + // Content without tag should be externally tagged (content is ignored) + assert_eq!(repr, SerdeEnumRepr::ExternallyTagged); + } + + // ================================================================= + // FALLBACK PATH TESTS FOR TAG/CONTENT (Lines 573, 583-590, 626) + // ================================================================= + + use proc_macro2::{Span, TokenStream}; + + /// Helper to create a serde attribute with raw tokens + fn create_enum_attr_with_raw_tokens(tokens: TokenStream) -> syn::Attribute { + syn::Attribute { + pound_token: syn::token::Pound::default(), + style: syn::AttrStyle::Outer, + bracket_token: syn::token::Bracket::default(), + meta: syn::Meta::List(syn::MetaList { + path: syn::Path::from(syn::Ident::new("serde", Span::call_site())), + delimiter: syn::MacroDelimiter::Paren(syn::token::Paren::default()), + tokens, + }), + } + } + + /// Test extract_tag fallback path - Lines 573, 583-590 + /// Forces manual token parsing by using qualified path + #[test] + fn test_extract_tag_fallback_path() { + let tokens: TokenStream = "my_module::tag = \"type\"".parse().unwrap(); + let attr = create_enum_attr_with_raw_tokens(tokens); + let result = extract_tag(&[attr]); + assert_eq!( + result.as_deref(), + Some("type"), + "Fallback should extract tag value" + ); + } + + /// Test extract_tag fallback with complex attributes + /// Lines 583-590: Tests the value extraction in fallback + #[test] + fn test_extract_tag_fallback_complex() { + let tokens: TokenStream = "crate::tag = \"kind\", rename_all = \"camelCase\"" + .parse() + .unwrap(); + let attr = create_enum_attr_with_raw_tokens(tokens); + let result = extract_tag(&[attr]); + assert_eq!(result.as_deref(), Some("kind")); + } + + /// Test extract_tag fallback doesn't match "untagged" + /// Line 581: before_char != 'n' check + #[test] + fn test_extract_tag_fallback_avoids_untagged() { + // "untagged" contains "tag" but should not be matched as tag = "..." + let tokens: TokenStream = "untagged".parse().unwrap(); + let attr = create_enum_attr_with_raw_tokens(tokens); + let result = extract_tag(&[attr]); + assert_eq!(result, None, "Should not extract tag from 'untagged'"); + } + + /// Test extract_tag fallback with tag after other attributes + #[test] + fn test_extract_tag_fallback_at_end() { + let tokens: TokenStream = "default, some_module::tag = \"variant\"".parse().unwrap(); + let attr = create_enum_attr_with_raw_tokens(tokens); + let result = extract_tag(&[attr]); + assert_eq!(result.as_deref(), Some("variant")); + } + + /// Test extract_content fallback path - Line 626 + /// Forces manual token parsing by using qualified path + #[test] + fn test_extract_content_fallback_path() { + let tokens: TokenStream = "my_module::content = \"data\"".parse().unwrap(); + let attr = create_enum_attr_with_raw_tokens(tokens); + let result = extract_content(&[attr]); + assert_eq!( + result.as_deref(), + Some("data"), + "Fallback should extract content value" + ); + } + + /// Test extract_content fallback with complex attributes + /// Line 626+: Tests the fallback token parsing branch + #[test] + fn test_extract_content_fallback_complex() { + let tokens: TokenStream = "crate::tag = \"type\", other::content = \"payload\"" + .parse() + .unwrap(); + let attr = create_enum_attr_with_raw_tokens(tokens); + let result = extract_content(&[attr]); + assert_eq!(result.as_deref(), Some("payload")); + } + + /// Test extract_content fallback with content at different position + #[test] + fn test_extract_content_fallback_at_start() { + let tokens: TokenStream = "some::content = \"body\", tag = \"kind\"".parse().unwrap(); + let attr = create_enum_attr_with_raw_tokens(tokens); + let result = extract_content(&[attr]); + assert_eq!(result.as_deref(), Some("body")); + } + + /// Test adjacently tagged using fallback paths for both tag and content + #[test] + fn test_extract_enum_repr_adjacently_tagged_fallback() { + let tokens: TokenStream = "mod1::tag = \"type\", mod2::content = \"data\"" + .parse() + .unwrap(); + let attr = create_enum_attr_with_raw_tokens(tokens); + let repr = extract_enum_repr(&[attr]); + assert_eq!( + repr, + SerdeEnumRepr::AdjacentlyTagged { + tag: "type".to_string(), + content: "data".to_string() + } + ); + } + + /// Test internally tagged using fallback path + #[test] + fn test_extract_enum_repr_internally_tagged_fallback() { + let tokens: TokenStream = "qualified::tag = \"discriminator\"".parse().unwrap(); + let attr = create_enum_attr_with_raw_tokens(tokens); + let repr = extract_enum_repr(&[attr]); + assert_eq!( + repr, + SerdeEnumRepr::InternallyTagged { + tag: "discriminator".to_string() + } + ); + } + + /// Helper to create a path-only serde attribute (#[serde] without parentheses) + /// This format causes require_list() to fail (returns Err) + fn create_path_only_serde_attr() -> syn::Attribute { + syn::Attribute { + pound_token: syn::token::Pound::default(), + style: syn::AttrStyle::Outer, + bracket_token: syn::token::Bracket::default(), + meta: syn::Meta::Path(syn::Path::from(syn::Ident::new("serde", Span::call_site()))), + } + } + + /// Test extract_tag with non-list serde attribute + /// When require_list() fails, extract_tag should continue to next attribute + #[test] + fn test_extract_tag_non_list_attr_continues() { + // First attr is path-only (#[serde]), second has the actual tag + let path_attr = create_path_only_serde_attr(); + let list_attr = { + let src = r#"#[serde(tag = "type")] enum Foo { A }"#; + let item: syn::ItemEnum = syn::parse_str(src).unwrap(); + item.attrs.into_iter().next().unwrap() + }; + + // extract_tag should skip the path-only attr and find tag in second attr + let result = extract_tag(&[path_attr, list_attr]); + assert_eq!(result.as_deref(), Some("type")); + } + + /// Test extract_tag with only non-list serde attribute returns None + #[test] + fn test_extract_tag_only_non_list_attr_returns_none() { + let path_attr = create_path_only_serde_attr(); + let result = extract_tag(&[path_attr]); + assert_eq!(result, None); + } + + /// Test extract_content with non-list serde attribute + /// When require_list() fails, extract_content should continue to next attribute + #[test] + fn test_extract_content_non_list_attr_continues() { + // First attr is path-only (#[serde]), second has the actual content + let path_attr = create_path_only_serde_attr(); + let list_attr = { + let src = r#"#[serde(content = "data")] enum Foo { A }"#; + let item: syn::ItemEnum = syn::parse_str(src).unwrap(); + item.attrs.into_iter().next().unwrap() + }; + + // extract_content should skip the path-only attr and find content in second attr + let result = extract_content(&[path_attr, list_attr]); + assert_eq!(result.as_deref(), Some("data")); + } + + /// Test extract_content with only non-list serde attribute returns None + #[test] + fn test_extract_content_only_non_list_attr_returns_none() { + let path_attr = create_path_only_serde_attr(); + let result = extract_content(&[path_attr]); + assert_eq!(result, None); + } +} diff --git a/crates/vespera_macro/src/parser/schema/serde_attrs/extract.rs b/crates/vespera_macro/src/parser/schema/serde_attrs/extract.rs new file mode 100644 index 00000000..ab863e43 --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/serde_attrs/extract.rs @@ -0,0 +1,442 @@ +use super::fallback::{ + contains_standalone_word, quoted_value_after_key, scan_default_from_raw_tokens, +}; + +pub fn extract_rename_all(attrs: &[syn::Attribute]) -> Option { + // First check serde attrs (higher priority) + for attr in attrs { + if attr.path().is_ident("serde") { + // Try using parse_nested_meta for robust parsing + let mut found_rename_all = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("rename_all") + && let Ok(value) = meta.value() + && let Ok(syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + })) = value.parse::() + { + found_rename_all = Some(s.value()); + } + Ok(()) + }); + if found_rename_all.is_some() { + return found_rename_all; + } + + // Fallback: manual token parsing for complex attribute combinations + let Ok(tokens) = attr.meta.require_list() else { + continue; + }; + let token_str = tokens.tokens.to_string(); + + if let Some(value) = quoted_value_after_key(&token_str, "rename_all") { + return Some(value); + } + } + } + + // Fallback: check for #[try_from_multipart(rename_all = "...")] + for attr in attrs { + if attr.path().is_ident("try_from_multipart") { + let mut found_rename_all = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("rename_all") + && let Ok(value) = meta.value() + && let Ok(syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + })) = value.parse::() + { + found_rename_all = Some(s.value()); + } + Ok(()) + }); + if found_rename_all.is_some() { + return found_rename_all; + } + } + } + + None +} + +pub fn extract_field_rename(attrs: &[syn::Attribute]) -> Option { + // First check serde attrs (higher priority) + for attr in attrs { + if attr.path().is_ident("serde") + && let syn::Meta::List(meta_list) = &attr.meta + { + // Use parse_nested_meta to parse nested attributes + let mut found_rename = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("rename") + && let Ok(value) = meta.value() + && let Ok(syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + })) = value.parse::() + { + found_rename = Some(s.value()); + } + Ok(()) + }); + if let Some(rename_value) = found_rename { + return Some(rename_value); + } + + // Fallback: manual token parsing for complex attribute combinations + let tokens = meta_list.tokens.to_string(); + if let Some(value) = quoted_value_after_key(&tokens, "rename") { + return Some(value); + } + } + } + + // Fallback: check for #[form_data(field_name = "...")] + for attr in attrs { + if attr.path().is_ident("form_data") { + let mut found_field_name = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("field_name") + && let Ok(value) = meta.value() + && let Ok(syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + })) = value.parse::() + { + found_field_name = Some(s.value()); + } + Ok(()) + }); + if found_field_name.is_some() { + return found_field_name; + } + } + } + + None +} + +/// Extract skip attribute from field attributes +/// Returns true if #[serde(skip)] is present +pub fn extract_skip(attrs: &[syn::Attribute]) -> bool { + for attr in attrs { + if attr.path().is_ident("serde") + && let syn::Meta::List(meta_list) = &attr.meta + { + let tokens = meta_list.tokens.to_string(); + // Check for "skip" (not part of skip_serializing_if or skip_deserializing) + if tokens.contains("skip") { + // Make sure it's not skip_serializing_if or skip_deserializing + if !tokens.contains("skip_serializing_if") && !tokens.contains("skip_deserializing") + { + // Check if it's a standalone "skip" + let skip_pos = tokens.find("skip"); + if let Some(pos) = skip_pos { + let before = if pos > 0 { &tokens[..pos] } else { "" }; + let after = &tokens[pos + "skip".len()..]; + // Check if skip is not part of another word + let before_char = before.chars().last().unwrap_or(' '); + let after_char = after.chars().next().unwrap_or(' '); + if (before_char == ' ' || before_char == ',' || before_char == '(') + && (after_char == ' ' || after_char == ',' || after_char == ')') + { + return true; + } + } + } + } + } + } + false +} + +/// Extract flatten attribute from field attributes +/// Returns true if #[serde(flatten)] is present +pub fn extract_flatten(attrs: &[syn::Attribute]) -> bool { + for attr in attrs { + if attr.path().is_ident("serde") { + // Try using parse_nested_meta for robust parsing + let mut found = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("flatten") { + found = true; + } + Ok(()) + }); + if found { + return true; + } + + // Fallback: manual token parsing for complex attribute combinations + if let syn::Meta::List(meta_list) = &attr.meta { + let tokens = meta_list.tokens.to_string(); + if contains_standalone_word(&tokens, "flatten") { + return true; + } + } + } + } + false +} + +/// Extract `skip_serializing_if` attribute from field attributes +/// Returns true if #[`serde(skip_serializing_if` = "...")] is present +pub fn extract_skip_serializing_if(attrs: &[syn::Attribute]) -> bool { + for attr in attrs { + if attr.path().is_ident("serde") + && let syn::Meta::List(meta_list) = &attr.meta + { + let mut found = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("skip_serializing_if") { + found = true; + } + Ok(()) + }); + if found { + return true; + } + + // Fallback: check tokens string for complex attribute combinations + let tokens = meta_list.tokens.to_string(); + if tokens.contains("skip_serializing_if") { + return true; + } + } + } + false +} + +/// Check whether the `"default"` substring at index `start` of `tokens` +/// Extract default attribute from field attributes +/// Returns: +/// - Some(None) if #[serde(default)] is present (no function) +/// - `Some(Some(function_name))` if #[serde(default = "`function_name`")] is present +/// - None if no default attribute is present +#[allow(clippy::option_option)] +pub fn extract_default(attrs: &[syn::Attribute]) -> Option> { + for attr in attrs { + if attr.path().is_ident("serde") + && let syn::Meta::List(meta_list) = &attr.meta + { + let mut found_default: Option> = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("default") { + // Check if it has a value (default = "function_name") + if let Ok(value) = meta.value() { + if let Ok(syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + })) = value.parse::() + { + found_default = Some(Some(s.value())); + } + } else { + // Just "default" without value + found_default = Some(None); + } + } + Ok(()) + }); + if found_default.is_none() { + // Fallback: manual token parsing for complex attribute combinations + found_default = scan_default_from_raw_tokens(&meta_list.tokens.to_string()); + } + if let Some(default_value) = found_default { + return Some(default_value); + } + } + } + None +} + +#[cfg(test)] +mod tests { + #![allow(clippy::option_option)] + use super::*; + use rstest::rstest; + #[rstest] + #[case(r#"#[serde(rename_all = "camelCase")] struct Foo;"#, Some("camelCase"))] + #[case( + r#"#[serde(rename_all = "snake_case")] struct Foo;"#, + Some("snake_case") + )] + #[case( + r#"#[serde(rename_all = "kebab-case")] struct Foo;"#, + Some("kebab-case") + )] + #[case( + r#"#[serde(rename_all = "PascalCase")] struct Foo;"#, + Some("PascalCase") + )] + // Multiple attributes - this is the bug case + #[case( + r#"#[serde(rename_all = "camelCase", default)] struct Foo;"#, + Some("camelCase") + )] + #[case( + r#"#[serde(default, rename_all = "snake_case")] struct Foo;"#, + Some("snake_case") + )] + #[case( + r#"#[serde(rename_all = "kebab-case", skip_serializing_if = "Option::is_none")] struct Foo;"#, + Some("kebab-case") +)] + // No rename_all + #[case(r"#[serde(default)] struct Foo;", None)] + #[case(r"#[derive(Debug)] struct Foo;", None)] + fn test_extract_rename_all(#[case] item_src: &str, #[case] expected: Option<&str>) { + let item: syn::ItemStruct = syn::parse_str(item_src).unwrap(); + let result = extract_rename_all(&item.attrs); + assert_eq!(result.as_deref(), expected); + } + + #[test] + fn test_extract_rename_all_enum_with_deny_unknown_fields() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(rename_all = "camelCase", deny_unknown_fields)] + enum Foo { A, B } + "#, + ) + .unwrap(); + let result = extract_rename_all(&enum_item.attrs); + assert_eq!(result.as_deref(), Some("camelCase")); + } + + // Tests for extract_field_rename function + #[rstest] + #[case(r#"#[serde(rename = "custom_name")] field: i32"#, Some("custom_name"))] + #[case(r#"#[serde(rename = "userId")] field: i32"#, Some("userId"))] + #[case(r#"#[serde(rename = "ID")] field: i32"#, Some("ID"))] + #[case(r"#[serde(default)] field: i32", None)] + #[case(r"#[serde(skip)] field: i32", None)] + #[case(r"field: i32", None)] + // rename_all should NOT be extracted as rename + #[case(r#"#[serde(rename_all = "camelCase")] field: i32"#, None)] + // Multiple attributes + #[case(r#"#[serde(rename = "custom", default)] field: i32"#, Some("custom"))] + #[case( + r#"#[serde(default, rename = "my_field")] field: i32"#, + Some("my_field") + )] + fn test_extract_field_rename(#[case] field_src: &str, #[case] expected: Option<&str>) { + // Parse field from struct context + let struct_src = format!("struct Foo {{ {field_src} }}"); + let item: syn::ItemStruct = syn::parse_str(&struct_src).unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + let field = fields.named.first().unwrap(); + let result = extract_field_rename(&field.attrs); + assert_eq!(result.as_deref(), expected, "Failed for: {field_src}"); + } + } + + // Tests for extract_skip function + #[rstest] + #[case(r"#[serde(skip)] field: i32", true)] + #[case(r"#[serde(default)] field: i32", false)] + #[case(r#"#[serde(rename = "x")] field: i32"#, false)] + #[case(r"field: i32", false)] + // skip_serializing_if should NOT be treated as skip + #[case( + r#"#[serde(skip_serializing_if = "Option::is_none")] field: i32"#, + false + )] + // skip_deserializing should NOT be treated as skip + #[case(r"#[serde(skip_deserializing)] field: i32", false)] + // Combined attributes + #[case(r"#[serde(skip, default)] field: i32", true)] + #[case(r"#[serde(default, skip)] field: i32", true)] + fn test_extract_skip(#[case] field_src: &str, #[case] expected: bool) { + let struct_src = format!("struct Foo {{ {field_src} }}"); + let item: syn::ItemStruct = syn::parse_str(&struct_src).unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + let field = fields.named.first().unwrap(); + let result = extract_skip(&field.attrs); + assert_eq!(result, expected, "Failed for: {field_src}"); + } + } + + // Tests for extract_flatten function + #[rstest] + #[case(r"#[serde(flatten)] field: i32", true)] + #[case(r"#[serde(default)] field: i32", false)] + #[case(r#"#[serde(rename = "x")] field: i32"#, false)] + #[case(r"field: i32", false)] + // Combined attributes + #[case(r"#[serde(flatten, default)] field: i32", true)] + #[case(r"#[serde(default, flatten)] field: i32", true)] + fn test_extract_flatten(#[case] field_src: &str, #[case] expected: bool) { + let struct_src = format!("struct Foo {{ {field_src} }}"); + let item: syn::ItemStruct = syn::parse_str(&struct_src).unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + let field = fields.named.first().unwrap(); + let result = extract_flatten(&field.attrs); + assert_eq!(result, expected, "Failed for: {field_src}"); + } + } + + // Tests for extract_skip_serializing_if function + #[rstest] + #[case( + r#"#[serde(skip_serializing_if = "Option::is_none")] field: i32"#, + true + )] + #[case(r#"#[serde(skip_serializing_if = "is_zero")] field: i32"#, true)] + #[case(r"#[serde(default)] field: i32", false)] + #[case(r"#[serde(skip)] field: i32", false)] + #[case(r"field: i32", false)] + fn test_extract_skip_serializing_if(#[case] field_src: &str, #[case] expected: bool) { + let struct_src = format!("struct Foo {{ {field_src} }}"); + let item: syn::ItemStruct = syn::parse_str(&struct_src).unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + let field = fields.named.first().unwrap(); + let result = extract_skip_serializing_if(&field.attrs); + assert_eq!(result, expected, "Failed for: {field_src}"); + } + } + + // Tests for extract_default function + #[rstest] + // Simple default (no function) + #[case(r"#[serde(default)] field: i32", Some(None))] + // Default with function name + #[case( + r#"#[serde(default = "default_value")] field: i32"#, + Some(Some("default_value")) + )] + #[case( + r#"#[serde(default = "Default::default")] field: i32"#, + Some(Some("Default::default")) + )] + // No default + #[case(r"#[serde(skip)] field: i32", None)] + #[case(r#"#[serde(rename = "x")] field: i32"#, None)] + #[case(r"field: i32", None)] + // Combined attributes + #[case( + r#"#[serde(default, skip_serializing_if = "Option::is_none")] field: i32"#, + Some(None) + )] + #[case( + r#"#[serde(skip_serializing_if = "Option::is_none", default = "my_default")] field: i32"#, + Some(Some("my_default")) + )] + fn test_extract_default( + #[case] field_src: &str, + #[case] + #[allow(clippy::option_option)] + expected: Option>, + ) { + let struct_src = format!("struct Foo {{ {field_src} }}"); + let item: syn::ItemStruct = syn::parse_str(&struct_src).unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + let field = fields.named.first().unwrap(); + let result = extract_default(&field.attrs); + let expected_owned = expected.map(|o| o.map(std::string::ToString::to_string)); + assert_eq!(result, expected_owned, "Failed for: {field_src}"); + } + } +} diff --git a/crates/vespera_macro/src/parser/schema/serde_attrs/fallback.rs b/crates/vespera_macro/src/parser/schema/serde_attrs/fallback.rs new file mode 100644 index 00000000..134f85de --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/serde_attrs/fallback.rs @@ -0,0 +1,733 @@ +pub(super) fn quoted_value_after_key(tokens: &str, key: &str) -> Option { + for (start, _) in tokens.match_indices(key) { + if key == "rename" && tokens[start..].starts_with("rename_all") { + continue; + } + if !is_standalone_word_at(tokens, start, key) && !is_qualified_key(tokens, start) { + continue; + } + let remaining = &tokens[start + key.len()..]; + let Some(equals_pos) = remaining.find('=') else { + continue; + }; + let value_part = remaining[equals_pos + 1..].trim(); + let Some(quote_start) = value_part.find('"') else { + continue; + }; + let after_quote = &value_part[quote_start + 1..]; + let Some(quote_end) = after_quote.find('"') else { + continue; + }; + return Some(after_quote[..quote_end].to_string()); + } + None +} + +pub(super) fn contains_standalone_word(tokens: &str, word: &str) -> bool { + tokens.match_indices(word).any(|(start, _)| { + is_standalone_word_at(tokens, start, word) || is_qualified_key(tokens, start) + }) +} + +fn is_qualified_key(tokens: &str, start: usize) -> bool { + start >= 2 && &tokens[start - 2..start] == "::" +} + +fn is_standalone_word_at(tokens: &str, start: usize, word: &str) -> bool { + let before = if start > 0 { &tokens[..start] } else { "" }; + let after = &tokens[start + word.len()..]; + let before_char = before.chars().last().unwrap_or(' '); + let after_char = after.chars().next().unwrap_or(' '); + let before_ok = before_char == ' ' || before_char == ',' || before_char == '('; + let after_ok = after_char == ' ' || after_char == ',' || after_char == ')' || after_char == '='; + before_ok && after_ok +} + +#[allow(clippy::option_option)] +pub(super) fn scan_default_from_raw_tokens(tokens: &str) -> Option> { + let start = tokens.find("default")?; + let remaining = &tokens[start + "default".len()..]; + if remaining.trim_start().starts_with('=') { + let after_equals = remaining + .trim_start() + .strip_prefix('=') + .unwrap_or("") + .trim_start(); + let quote_start = after_equals.find('"')?; + let after_quote = &after_equals[quote_start + 1..]; + let quote_end = after_quote.find('"')?; + Some(Some(after_quote[..quote_end].to_string())) + } else if is_standalone_word_at(tokens, start, "default") { + Some(None) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use crate::parser::schema::serde_attrs::*; + use proc_macro2::{Span, TokenStream}; + use quote::quote; + use rstest::rstest; + + /// Helper to create attributes by parsing a struct with the given serde attributes + fn get_struct_attrs(serde_content: &str) -> Vec { + let src = format!(r"#[serde({serde_content})] struct Foo;"); + let item: syn::ItemStruct = syn::parse_str(&src).unwrap(); + item.attrs + } + + /// Helper to create field attributes by parsing a struct with the field + fn get_field_attrs(serde_content: &str) -> Vec { + let src = format!(r"struct Foo {{ #[serde({serde_content})] field: i32 }}"); + let item: syn::ItemStruct = syn::parse_str(&src).unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + fields.named.first().unwrap().attrs.clone() + } else { + vec![] + } + } + + /// Create a serde attribute with programmatic tokens + fn create_attr_with_raw_tokens(tokens: TokenStream) -> syn::Attribute { + syn::Attribute { + pound_token: syn::token::Pound::default(), + style: syn::AttrStyle::Outer, + bracket_token: syn::token::Bracket::default(), + meta: syn::Meta::List(syn::MetaList { + path: syn::Path::from(syn::Ident::new("serde", Span::call_site())), + delimiter: syn::MacroDelimiter::Paren(syn::token::Paren::default()), + tokens, + }), + } + } + + /// Test extract_rename_all fallback by creating an attribute where + /// parse_nested_meta succeeds but doesn't find rename_all in the expected format + #[test] + fn test_extract_rename_all_fallback_path() { + // Standard path - parse_nested_meta should work + let attrs = get_struct_attrs(r#"rename_all = "camelCase""#); + let result = extract_rename_all(&attrs); + assert_eq!(result.as_deref(), Some("camelCase")); + } + + /// Test extract_field_rename fallback + #[test] + fn test_extract_field_rename_fallback_path() { + // Standard path + let attrs = get_field_attrs(r#"rename = "myField""#); + let result = extract_field_rename(&attrs); + assert_eq!(result.as_deref(), Some("myField")); + } + + /// Test extract_skip_serializing_if with fallback token check + #[test] + fn test_extract_skip_serializing_if_fallback_path() { + let attrs = get_field_attrs(r#"skip_serializing_if = "Option::is_none""#); + let result = extract_skip_serializing_if(&attrs); + assert!(result); + } + + /// Test extract_default standalone fallback + #[test] + fn test_extract_default_standalone_fallback_path() { + // Simple default without function + let attrs = get_field_attrs(r"default"); + let result = extract_default(&attrs); + assert_eq!(result, Some(None)); + } + + /// Test extract_default fallback when parse_nested_meta can't see `default` + /// at the top level — forces the manual token scan to catch it. + #[test] + fn test_extract_default_standalone_fallback_when_nested_meta_fails() { + // Construct an attribute whose token stream begins with garbage + // that `parse_nested_meta` will refuse to parse (a stray `@` + // before the first key). Because the parser bails immediately, + // the callback for `default` never fires, and the manual + // token-string fallback at the end of `extract_default` is the + // only path that detects the standalone `default` keyword. + let tokens: TokenStream = "@bogus, default".parse().expect("token stream parses"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_default(&[attr]); + assert_eq!( + result, + Some(None), + "fallback path must detect bare `default`" + ); + } + + /// Test that the fallback's "default appears as a substring inside + /// another identifier" branch returns None (no false-positive + /// match). Exercises the trailing `None` arm of + /// `scan_default_from_raw_tokens` (substring found, but neither + /// `=` follows nor delimiter chars surround it). + #[test] + fn test_extract_default_substring_in_identifier_is_not_a_match() { + // `field_default` contains "default" but as a suffix of an + // identifier — `before_char` is `_`, not one of the valid + // delimiters, so the standalone check fails. + let tokens: TokenStream = "@bogus, field_default" + .parse() + .expect("token stream parses"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_default(&[attr]); + assert_eq!( + result, None, + "embedded 'default' substring must not register as default" + ); + } + + /// Test extract_default with function fallback + #[test] + fn test_extract_default_with_function_fallback_path() { + let attrs = get_field_attrs(r#"default = "my_default_fn""#); + let result = extract_default(&attrs); + assert_eq!(result, Some(Some("my_default_fn".to_string()))); + } + + /// Test that rename_all is NOT confused with rename + #[test] + fn test_extract_field_rename_avoids_rename_all() { + let attrs = get_field_attrs(r#"rename_all = "camelCase""#); + let result = extract_field_rename(&attrs); + assert_eq!(result, None); // Should NOT extract rename_all as rename + } + + /// Test empty serde attribute + #[test] + fn test_extract_functions_with_empty_serde() { + let item: syn::ItemStruct = syn::parse_str(r"#[serde()] struct Foo;").unwrap(); + assert_eq!(extract_rename_all(&item.attrs), None); + } + + /// Test non-serde attribute is ignored + #[test] + fn test_extract_functions_ignore_non_serde() { + let item: syn::ItemStruct = syn::parse_str(r"#[derive(Debug)] struct Foo;").unwrap(); + assert_eq!(extract_rename_all(&item.attrs), None); + assert_eq!(extract_field_rename(&item.attrs), None); + } + + /// Test serde attribute that is not a list (e.g., #[serde]) + #[test] + fn test_extract_rename_all_non_list_serde() { + // #[serde] without parentheses - this should just be ignored + let item: syn::ItemStruct = syn::parse_str(r"#[serde] struct Foo;").unwrap(); + let result = extract_rename_all(&item.attrs); + assert_eq!(result, None); + } + + /// Test extract_field_rename with complex attribute + #[test] + fn test_extract_field_rename_complex_attr() { + let attrs = get_field_attrs( + r#"default, rename = "field_name", skip_serializing_if = "Option::is_none""#, + ); + let result = extract_field_rename(&attrs); + assert_eq!(result.as_deref(), Some("field_name")); + } + + /// Test extract_rename_all with multiple serde attributes on same item + #[test] + fn test_extract_rename_all_multiple_serde_attrs() { + let item: syn::ItemStruct = syn::parse_str( + r#" + #[serde(default)] + #[serde(rename_all = "snake_case")] + struct Foo; + "#, + ) + .unwrap(); + let result = extract_rename_all(&item.attrs); + assert_eq!(result.as_deref(), Some("snake_case")); + } + + /// Test edge case: rename_all with extra whitespace (manual parsing should handle) + #[test] + fn test_extract_rename_all_with_whitespace() { + // Note: syn normalizes whitespace in parsed tokens, so this tests the robust parsing + let attrs = get_struct_attrs(r#"rename_all = "PascalCase""#); + let result = extract_rename_all(&attrs); + assert_eq!(result.as_deref(), Some("PascalCase")); + } + + /// Test edge case: rename at various positions + #[test] + fn test_extract_field_rename_at_end() { + let attrs = get_field_attrs(r#"skip_serializing_if = "is_none", rename = "lastField""#); + let result = extract_field_rename(&attrs); + assert_eq!(result.as_deref(), Some("lastField")); + } + + /// Test extract_default when it appears with other attrs + #[test] + fn test_extract_default_among_other_attrs() { + let attrs = + get_field_attrs(r#"skip_serializing_if = "is_none", default, rename = "field""#); + let result = extract_default(&attrs); + assert_eq!(result, Some(None)); + } + + /// Test extract_skip - basic functionality + #[test] + fn test_extract_skip_basic() { + let attrs = get_field_attrs(r"skip"); + let result = extract_skip(&attrs); + assert!(result); + } + + /// Test extract_skip does not trigger for skip_serializing_if + #[test] + fn test_extract_skip_not_skip_serializing_if() { + let attrs = get_field_attrs(r#"skip_serializing_if = "Option::is_none""#); + let result = extract_skip(&attrs); + assert!(!result); + } + + /// Test extract_skip does not trigger for skip_deserializing + #[test] + fn test_extract_skip_not_skip_deserializing() { + let attrs = get_field_attrs(r"skip_deserializing"); + let result = extract_skip(&attrs); + assert!(!result); + } + + /// Test extract_skip with combined attrs + #[test] + fn test_extract_skip_with_other_attrs() { + let attrs = get_field_attrs(r"skip, default"); + let result = extract_skip(&attrs); + assert!(result); + } + + /// Test extract_default function with path containing colons + #[test] + fn test_extract_default_with_path() { + let attrs = get_field_attrs(r#"default = "Default::default""#); + let result = extract_default(&attrs); + assert_eq!(result, Some(Some("Default::default".to_string()))); + } + + /// Test extract_skip_serializing_if with complex path + #[test] + fn test_extract_skip_serializing_if_complex_path() { + let attrs = get_field_attrs(r#"skip_serializing_if = "Vec::is_empty""#); + let result = extract_skip_serializing_if(&attrs); + assert!(result); + } + + /// Test extract_rename_all with all supported formats + #[rstest] + #[case("camelCase")] + #[case("snake_case")] + #[case("kebab-case")] + #[case("PascalCase")] + #[case("lowercase")] + #[case("UPPERCASE")] + #[case("SCREAMING_SNAKE_CASE")] + #[case("SCREAMING-KEBAB-CASE")] + fn test_extract_rename_all_all_formats(#[case] format: &str) { + let attrs = get_struct_attrs(&format!(r#"rename_all = "{format}""#)); + let result = extract_rename_all(&attrs); + assert_eq!(result.as_deref(), Some(format)); + } + + /// Test non-serde attribute doesn't affect extraction + #[test] + fn test_mixed_attributes() { + let item: syn::ItemStruct = syn::parse_str( + r#" + #[derive(Debug, Clone)] + #[serde(rename_all = "camelCase")] + #[doc = "Some documentation"] + struct Foo; + "#, + ) + .unwrap(); + let result = extract_rename_all(&item.attrs); + assert_eq!(result.as_deref(), Some("camelCase")); + } + + /// Test field with multiple serde attributes + #[test] + fn test_field_multiple_serde_attrs() { + let item: syn::ItemStruct = syn::parse_str( + r#" + struct Foo { + #[serde(default)] + #[serde(rename = "customName")] + field: i32 + } + "#, + ) + .unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + let attrs = &fields.named.first().unwrap().attrs; + let rename = extract_field_rename(attrs); + let default = extract_default(attrs); + assert_eq!(rename.as_deref(), Some("customName")); + assert_eq!(default, Some(None)); + } + } + + /// Test extract_rename_all with programmatic tokens + #[test] + fn test_extract_rename_all_programmatic() { + let tokens = quote!(rename_all = "camelCase"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert_eq!(result.as_deref(), Some("camelCase")); + } + + /// Test extract_rename_all with invalid value (not a string) + #[test] + fn test_extract_rename_all_invalid_value() { + let tokens = quote!(rename_all = camelCase); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + // parse_nested_meta won't find a string literal + assert!(result.is_none()); + } + + /// Test extract_rename_all with missing equals sign + #[test] + fn test_extract_rename_all_no_equals() { + let tokens = quote!(rename_all "camelCase"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert!(result.is_none()); + } + + /// Test extract_field_rename with programmatic tokens + #[test] + fn test_extract_field_rename_programmatic() { + let tokens = quote!(rename = "customField"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_field_rename(&[attr]); + assert_eq!(result.as_deref(), Some("customField")); + } + + /// Test extract_default standalone with programmatic tokens + #[test] + fn test_extract_default_programmatic() { + let tokens = quote!(default); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_default(&[attr]); + assert_eq!(result, Some(None)); + } + + /// Test extract_default with function via programmatic tokens + #[test] + fn test_extract_default_with_fn_programmatic() { + let tokens = quote!(default = "my_fn"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_default(&[attr]); + assert_eq!(result, Some(Some("my_fn".to_string()))); + } + + /// Test extract_skip_serializing_if with programmatic tokens + #[test] + fn test_extract_skip_serializing_if_programmatic() { + let tokens = quote!(skip_serializing_if = "is_none"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_skip_serializing_if(&[attr]); + assert!(result); + } + + /// Test extract_skip via programmatic tokens + #[test] + fn test_extract_skip_programmatic() { + let tokens = quote!(skip); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_skip(&[attr]); + assert!(result); + } + + /// Test that rename_all is not confused with rename + #[test] + fn test_rename_all_not_rename() { + let tokens = quote!(rename_all = "camelCase"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_field_rename(&[attr]); + assert_eq!(result, None); + } + + /// Test multiple items in serde attribute + #[test] + fn test_multiple_items_programmatic() { + let tokens = quote!(default, rename = "myField", skip_serializing_if = "is_none"); + let attr = create_attr_with_raw_tokens(tokens); + + let rename_result = extract_field_rename(std::slice::from_ref(&attr)); + let default_result = extract_default(std::slice::from_ref(&attr)); + let skip_if_result = extract_skip_serializing_if(std::slice::from_ref(&attr)); + + assert_eq!(rename_result.as_deref(), Some("myField")); + assert_eq!(default_result, Some(None)); + assert!(skip_if_result); + } + + /// Test extract_rename_all fallback parsing + #[test] + fn test_extract_rename_all_fallback_manual_parsing() { + let tokens = quote!(rename_all = "kebab-case"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert_eq!(result.as_deref(), Some("kebab-case")); + } + + /// Test extract_rename_all with complex attribute that forces fallback + #[test] + fn test_extract_rename_all_complex_attribute_fallback() { + let tokens = quote!(default, rename_all = "SCREAMING_SNAKE_CASE", skip); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert_eq!(result.as_deref(), Some("SCREAMING_SNAKE_CASE")); + } + + /// Test extract_rename_all when value is not a string literal + #[test] + fn test_extract_rename_all_no_quote_start() { + let tokens = quote!(rename_all = snake_case); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert!(result.is_none()); + } + + /// Test extract_rename_all with unclosed quote + #[test] + fn test_extract_rename_all_unclosed_quote() { + let tokens = quote!(rename_all = "camelCase"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert_eq!(result.as_deref(), Some("camelCase")); + } + + /// Test extract_rename_all with empty string value + #[test] + fn test_extract_rename_all_empty_string() { + let tokens = quote!(rename_all = ""); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert_eq!(result.as_deref(), Some("")); + } + + /// Test extract_rename_all with QUALIFIED PATH to force fallback + #[test] + fn test_extract_rename_all_qualified_path_forces_fallback() { + let tokens = quote!(serde_with::rename_all = "camelCase"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert_eq!(result.as_deref(), Some("camelCase")); + } + + /// Test extract_rename_all with another qualified path variation + #[test] + fn test_extract_rename_all_module_qualified_forces_fallback() { + let tokens = quote!(my_module::rename_all = "snake_case"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert_eq!(result.as_deref(), Some("snake_case")); + } + + /// Test extract_rename_all with deeply qualified path + #[test] + fn test_extract_rename_all_deeply_qualified_forces_fallback() { + let tokens = quote!(a::b::rename_all = "PascalCase"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert_eq!(result.as_deref(), Some("PascalCase")); + } + + /// CRITICAL TEST: This test MUST hit fallback path + #[test] + fn test_extract_rename_all_raw_tokens_force_fallback() { + let tokens: TokenStream = "__rename_all_prefix::rename_all = \"lowercase\"" + .parse() + .unwrap(); + let attr = create_attr_with_raw_tokens(tokens); + + if let syn::Meta::List(list) = &attr.meta { + let token_str = list.tokens.to_string(); + assert!( + token_str.contains("rename_all"), + "Token string should contain rename_all: {token_str}" + ); + } + + let result = extract_rename_all(&[attr]); + assert_eq!( + result.as_deref(), + Some("lowercase"), + "Fallback parsing must extract the value" + ); + } + + /// Another critical test with different qualified path format + #[test] + fn test_extract_rename_all_crate_qualified_forces_fallback() { + let tokens: TokenStream = "crate::rename_all = \"UPPERCASE\"".parse().unwrap(); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert_eq!(result.as_deref(), Some("UPPERCASE")); + } + + /// Test with self:: prefix + #[test] + fn test_extract_rename_all_self_qualified_forces_fallback() { + let tokens: TokenStream = "self::rename_all = \"kebab-case\"".parse().unwrap(); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert_eq!(result.as_deref(), Some("kebab-case")); + } + + // ================================================================= + // FALLBACK PATH TESTS (Lines 173, 258-265, 573, 583-590, 626) + // ================================================================= + + /// Test extract_field_rename fallback path - Line 173 + /// Tests the word boundary check when "rename" appears with other attributes + /// This triggers the manual token parsing fallback when parse_nested_meta + /// doesn't extract the value in expected format + #[test] + fn test_extract_field_rename_fallback_word_boundary() { + // Create attribute with qualified path to force fallback + let tokens: TokenStream = "my_module::rename = \"value\"".parse().unwrap(); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_field_rename(&[attr]); + assert_eq!(result.as_deref(), Some("value")); + } + + /// Test extract_field_rename fallback - complex combined attributes + /// Line 173: Tests the edge case of word boundary checking + #[test] + fn test_extract_field_rename_fallback_complex_attr() { + // Qualified path forces parse_nested_meta to not find "rename" + let tokens: TokenStream = "crate::other::rename = \"custom_field\", default" + .parse() + .unwrap(); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_field_rename(&[attr]); + assert_eq!(result.as_deref(), Some("custom_field")); + } + + /// Test extract_field_rename - ensure rename_all is not matched as rename + /// Test the word boundary logic + #[test] + fn test_extract_field_rename_fallback_avoids_rename_all() { + let tokens: TokenStream = "some::rename_all = \"camelCase\"".parse().unwrap(); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_field_rename(&[attr]); + // Should NOT match rename_all as rename + assert_eq!(result, None); + } + + /// Test extract_flatten fallback path - Lines 258-265 + /// Forces manual token parsing by using qualified path + #[test] + fn test_extract_flatten_fallback_path() { + let tokens: TokenStream = "my_module::flatten".parse().unwrap(); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_flatten(&[attr]); + assert!(result, "Fallback should find 'flatten' in token string"); + } + + /// Test extract_flatten fallback with complex attributes + /// Lines 258-263: Tests word boundary checking in fallback + #[test] + fn test_extract_flatten_fallback_complex() { + let tokens: TokenStream = "crate::flatten, default = \"my_fn\"".parse().unwrap(); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_flatten(&[attr]); + assert!(result, "Fallback should detect flatten with other attrs"); + } + + /// Test extract_flatten fallback with flatten at different positions + /// Line 265: Tests the return true path in fallback + #[test] + fn test_extract_flatten_fallback_at_end() { + let tokens: TokenStream = "default, some::flatten".parse().unwrap(); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_flatten(&[attr]); + assert!(result); + } + + /// Test extract_flatten fallback doesn't match partial words + #[test] + fn test_extract_flatten_fallback_no_partial_match() { + // "flattened" should not match "flatten" + let tokens: TokenStream = "flattened".parse().unwrap(); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_flatten(&[attr]); + assert!(!result, "Should not match 'flattened' as 'flatten'"); + } + // ================================================================= + // MULTIPART FALLBACK TESTS (form_data / try_from_multipart) + // ================================================================= + + /// Test extract_field_rename falls back to #[form_data(field_name = "...")] + #[test] + fn test_extract_field_rename_form_data_fallback() { + let struct_src = r#"struct Foo { #[form_data(field_name = "my_file")] field: i32 }"#; + let item: syn::ItemStruct = syn::parse_str(struct_src).unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + let field = fields.named.first().unwrap(); + let result = extract_field_rename(&field.attrs); + assert_eq!(result.as_deref(), Some("my_file")); + } + } + + /// Test serde rename takes priority over form_data field_name + #[test] + fn test_extract_field_rename_serde_over_form_data() { + let struct_src = r#"struct Foo { #[serde(rename = "serde_name")] #[form_data(field_name = "form_name")] field: i32 }"#; + let item: syn::ItemStruct = syn::parse_str(struct_src).unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + let field = fields.named.first().unwrap(); + let result = extract_field_rename(&field.attrs); + assert_eq!(result.as_deref(), Some("serde_name")); + } + } + + /// Test extract_field_rename with form_data but no field_name key + #[test] + fn test_extract_field_rename_form_data_no_field_name() { + let struct_src = r#"struct Foo { #[form_data(limit = "10MiB")] field: i32 }"#; + let item: syn::ItemStruct = syn::parse_str(struct_src).unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + let field = fields.named.first().unwrap(); + let result = extract_field_rename(&field.attrs); + assert_eq!(result, None); + } + } + + /// Test extract_rename_all falls back to #[try_from_multipart(rename_all = "...")] + #[test] + fn test_extract_rename_all_try_from_multipart_fallback() { + let item: syn::ItemStruct = + syn::parse_str(r#"#[try_from_multipart(rename_all = "camelCase")] struct Foo;"#) + .unwrap(); + let result = extract_rename_all(&item.attrs); + assert_eq!(result.as_deref(), Some("camelCase")); + } + + /// Test serde rename_all takes priority over try_from_multipart rename_all + #[test] + fn test_extract_rename_all_serde_over_try_from_multipart() { + let item: syn::ItemStruct = syn::parse_str(r#"#[serde(rename_all = "snake_case")] #[try_from_multipart(rename_all = "camelCase")] struct Foo;"#).unwrap(); + let result = extract_rename_all(&item.attrs); + assert_eq!(result.as_deref(), Some("snake_case")); + } + + /// Test extract_rename_all with try_from_multipart but no rename_all key + #[test] + fn test_extract_rename_all_try_from_multipart_no_rename_all() { + let item: syn::ItemStruct = + syn::parse_str(r"#[try_from_multipart(strict)] struct Foo;").unwrap(); + let result = extract_rename_all(&item.attrs); + assert_eq!(result, None); + } +} diff --git a/crates/vespera_macro/src/parser/schema/serde_attrs/rename_case.rs b/crates/vespera_macro/src/parser/schema/serde_attrs/rename_case.rs new file mode 100644 index 00000000..a6020fd9 --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/serde_attrs/rename_case.rs @@ -0,0 +1,243 @@ +#[allow(clippy::too_many_lines)] +pub fn rename_field(field_name: &str, rename_all: Option<&str>) -> String { + // "lowercase", "UPPERCASE", "PascalCase", "camelCase", "snake_case", "SCREAMING_SNAKE_CASE", "kebab-case", "SCREAMING-KEBAB-CASE" + match rename_all { + Some("camelCase") => { + // Convert snake_case or PascalCase to camelCase + let mut result = String::new(); + let mut capitalize_next = false; + let mut in_first_word = true; + let chars: Vec = field_name.chars().collect(); + + for (i, &ch) in chars.iter().enumerate() { + if ch == '_' { + capitalize_next = true; + in_first_word = false; + continue; + } + if in_first_word { + // In first word: lowercase until we hit a word boundary + // Word boundary: uppercase char followed by lowercase (e.g., "XMLParser" -> "P" starts new word) + let next_is_lower = chars.get(i + 1).is_some_and(|c| c.is_lowercase()); + if ch.is_uppercase() && next_is_lower && i > 0 { + // This uppercase starts a new word (e.g., 'P' in "XMLParser") + in_first_word = false; + result.push(ch); + } else { + // Still in first word, lowercase it + result.push(ch.to_ascii_lowercase()); + } + continue; + } + if capitalize_next { + result.push(ch.to_ascii_uppercase()); + capitalize_next = false; + continue; + } + result.push(ch); + } + result + } + Some("snake_case") => { + // Convert camelCase to snake_case + let mut result = String::new(); + for (i, ch) in field_name.chars().enumerate() { + if ch.is_uppercase() && i > 0 { + result.push('_'); + } + result.push(ch.to_ascii_lowercase()); + } + result + } + Some("kebab-case") => { + // Convert snake_case or Camel/PascalCase to kebab-case (lowercase with hyphens) + let mut result = String::new(); + for (i, ch) in field_name.chars().enumerate() { + if ch.is_uppercase() { + if i > 0 && !result.ends_with('-') { + result.push('-'); + } + result.push(ch.to_ascii_lowercase()); + } else if ch == '_' { + result.push('-'); + } else { + result.push(ch); + } + } + result + } + Some("PascalCase") => { + // Convert snake_case to PascalCase + let mut result = String::new(); + let mut capitalize_next = true; + for ch in field_name.chars() { + if ch == '_' { + capitalize_next = true; + } else if capitalize_next { + result.push(ch.to_ascii_uppercase()); + capitalize_next = false; + } else { + result.push(ch); + } + } + result + } + Some("lowercase") => { + // Convert to lowercase + field_name.to_lowercase() + } + Some("UPPERCASE") => { + // Convert to UPPERCASE + field_name.to_uppercase() + } + Some("SCREAMING_SNAKE_CASE") => { + // Convert to SCREAMING_SNAKE_CASE + // If already in SCREAMING_SNAKE_CASE format, return as is + if field_name.chars().all(|c| c.is_uppercase() || c == '_') && field_name.contains('_') + { + return field_name.to_string(); + } + // First convert to snake_case if needed, then uppercase + let mut snake_case = String::new(); + for (i, ch) in field_name.chars().enumerate() { + if ch.is_uppercase() && i > 0 && !snake_case.ends_with('_') { + snake_case.push('_'); + } + if ch != '_' && ch != '-' { + snake_case.push(ch.to_ascii_lowercase()); + } else if ch == '_' { + snake_case.push('_'); + } + } + snake_case.to_uppercase() + } + Some("SCREAMING-KEBAB-CASE") => { + // Convert to SCREAMING-KEBAB-CASE + // First convert to kebab-case if needed, then uppercase + let mut kebab_case = String::new(); + for (i, ch) in field_name.chars().enumerate() { + if ch.is_uppercase() + && i > 0 + && !kebab_case.ends_with('-') + && !kebab_case.ends_with('_') + { + kebab_case.push('-'); + } + if ch == '_' { + kebab_case.push('-'); + } else if ch != '-' { + kebab_case.push(ch.to_ascii_lowercase()); + } else { + kebab_case.push('-'); + } + } + kebab_case.to_uppercase() + } + _ => field_name.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + #[rstest] + // camelCase tests (snake_case input) + #[case("user_name", Some("camelCase"), "userName")] + #[case("first_name", Some("camelCase"), "firstName")] + #[case("last_name", Some("camelCase"), "lastName")] + #[case("user_id", Some("camelCase"), "userId")] + #[case("api_key", Some("camelCase"), "apiKey")] + #[case("already_camel", Some("camelCase"), "alreadyCamel")] + // camelCase tests (PascalCase input) + #[case("UserName", Some("camelCase"), "userName")] + #[case("UserCreated", Some("camelCase"), "userCreated")] + #[case("FirstName", Some("camelCase"), "firstName")] + #[case("ID", Some("camelCase"), "id")] + #[case("XMLParser", Some("camelCase"), "xmlParser")] + #[case("HTTPSConnection", Some("camelCase"), "httpsConnection")] + // snake_case tests + #[case("userName", Some("snake_case"), "user_name")] + #[case("firstName", Some("snake_case"), "first_name")] + #[case("lastName", Some("snake_case"), "last_name")] + #[case("userId", Some("snake_case"), "user_id")] + #[case("apiKey", Some("snake_case"), "api_key")] + #[case("already_snake", Some("snake_case"), "already_snake")] + // kebab-case tests + #[case("user_name", Some("kebab-case"), "user-name")] + #[case("first_name", Some("kebab-case"), "first-name")] + #[case("last_name", Some("kebab-case"), "last-name")] + #[case("user_id", Some("kebab-case"), "user-id")] + #[case("api_key", Some("kebab-case"), "api-key")] + #[case("already-kebab", Some("kebab-case"), "already-kebab")] + // PascalCase tests + #[case("user_name", Some("PascalCase"), "UserName")] + #[case("first_name", Some("PascalCase"), "FirstName")] + #[case("last_name", Some("PascalCase"), "LastName")] + #[case("user_id", Some("PascalCase"), "UserId")] + #[case("api_key", Some("PascalCase"), "ApiKey")] + #[case("AlreadyPascal", Some("PascalCase"), "AlreadyPascal")] + // lowercase tests + #[case("UserName", Some("lowercase"), "username")] + #[case("FIRST_NAME", Some("lowercase"), "first_name")] + #[case("lastName", Some("lowercase"), "lastname")] + #[case("User_ID", Some("lowercase"), "user_id")] + #[case("API_KEY", Some("lowercase"), "api_key")] + #[case("already_lower", Some("lowercase"), "already_lower")] + // UPPERCASE tests + #[case("user_name", Some("UPPERCASE"), "USER_NAME")] + #[case("firstName", Some("UPPERCASE"), "FIRSTNAME")] + #[case("LastName", Some("UPPERCASE"), "LASTNAME")] + #[case("user_id", Some("UPPERCASE"), "USER_ID")] + #[case("apiKey", Some("UPPERCASE"), "APIKEY")] + #[case("ALREADY_UPPER", Some("UPPERCASE"), "ALREADY_UPPER")] + // SCREAMING_SNAKE_CASE tests + #[case("user_name", Some("SCREAMING_SNAKE_CASE"), "USER_NAME")] + #[case("firstName", Some("SCREAMING_SNAKE_CASE"), "FIRST_NAME")] + #[case("LastName", Some("SCREAMING_SNAKE_CASE"), "LAST_NAME")] + #[case("user_id", Some("SCREAMING_SNAKE_CASE"), "USER_ID")] + #[case("apiKey", Some("SCREAMING_SNAKE_CASE"), "API_KEY")] + #[case("ALREADY_SCREAMING", Some("SCREAMING_SNAKE_CASE"), "ALREADY_SCREAMING")] + // SCREAMING-KEBAB-CASE tests + #[case("user_name", Some("SCREAMING-KEBAB-CASE"), "USER-NAME")] + #[case("firstName", Some("SCREAMING-KEBAB-CASE"), "FIRST-NAME")] + #[case("LastName", Some("SCREAMING-KEBAB-CASE"), "LAST-NAME")] + #[case("user_id", Some("SCREAMING-KEBAB-CASE"), "USER-ID")] + #[case("apiKey", Some("SCREAMING-KEBAB-CASE"), "API-KEY")] + #[case("already-kebab", Some("SCREAMING-KEBAB-CASE"), "ALREADY-KEBAB")] + // None tests (no transformation) + #[case("user_name", None, "user_name")] + #[case("firstName", None, "firstName")] + #[case("LastName", None, "LastName")] + #[case("user-id", None, "user-id")] + fn test_rename_field( + #[case] field_name: &str, + #[case] rename_all: Option<&str>, + #[case] expected: &str, + ) { + assert_eq!(rename_field(field_name, rename_all), expected); + } + // Test camelCase transformation with mixed characters + #[test] + fn test_rename_field_camelcase_with_digits() { + // Tests the regular character branch in camelCase + let result = rename_field("user_id_123", Some("camelCase")); + assert_eq!(result, "userId123"); + + let result = rename_field("get_user_by_id", Some("camelCase")); + assert_eq!(result, "getUserById"); + } + // Test rename_field with unknown/invalid rename_all format - should return original field name + #[test] + fn test_rename_field_unknown_format() { + // Unknown format should return the original field name unchanged + let result = rename_field("my_field", Some("unknown_format")); + assert_eq!(result, "my_field"); + + let result = rename_field("myField", Some("invalid")); + assert_eq!(result, "myField"); + + let result = rename_field("test_name", Some("not_a_real_format")); + assert_eq!(result, "test_name"); + } +} diff --git a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__adjacently_tagged_snapshot@adjacently_tagged.snap b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__representations__tests__adjacently_tagged_snapshot@adjacently_tagged.snap similarity index 100% rename from crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__adjacently_tagged_snapshot@adjacently_tagged.snap rename to crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__representations__tests__adjacently_tagged_snapshot@adjacently_tagged.snap diff --git a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__externally_tagged_empty_struct_variant@externally_tagged_empty_struct.snap b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__representations__tests__externally_tagged_empty_struct_variant@externally_tagged_empty_struct.snap similarity index 100% rename from crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__externally_tagged_empty_struct_variant@externally_tagged_empty_struct.snap rename to crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__representations__tests__externally_tagged_empty_struct_variant@externally_tagged_empty_struct.snap diff --git a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__internally_tagged_skips_tuple_variant@internally_tagged_skip_tuple.snap b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__representations__tests__internally_tagged_skips_tuple_variant@internally_tagged_skip_tuple.snap similarity index 100% rename from crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__internally_tagged_skips_tuple_variant@internally_tagged_skip_tuple.snap rename to crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__representations__tests__internally_tagged_skips_tuple_variant@internally_tagged_skip_tuple.snap diff --git a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__internally_tagged_snapshot@internally_tagged.snap b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__representations__tests__internally_tagged_snapshot@internally_tagged.snap similarity index 100% rename from crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__internally_tagged_snapshot@internally_tagged.snap rename to crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__representations__tests__internally_tagged_snapshot@internally_tagged.snap diff --git a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__untagged_multi_field_tuple_variant@untagged_multi_field_tuple.snap b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__representations__tests__untagged_multi_field_tuple_variant@untagged_multi_field_tuple.snap similarity index 100% rename from crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__untagged_multi_field_tuple_variant@untagged_multi_field_tuple.snap rename to crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__representations__tests__untagged_multi_field_tuple_variant@untagged_multi_field_tuple.snap diff --git a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__untagged_snapshot@untagged.snap b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__representations__tests__untagged_snapshot@untagged.snap similarity index 100% rename from crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__untagged_snapshot@untagged.snap rename to crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__representations__tests__untagged_snapshot@untagged.snap diff --git a/crates/vespera_macro/src/parser/schema/type_schema.rs b/crates/vespera_macro/src/parser/schema/type_schema.rs index 707e7c38..d83789b5 100644 --- a/crates/vespera_macro/src/parser/schema/type_schema.rs +++ b/crates/vespera_macro/src/parser/schema/type_schema.rs @@ -1,422 +1,21 @@ -//! Type to JSON Schema conversion for `OpenAPI` generation. -//! -//! This module handles the conversion of Rust types (as parsed by syn) -//! into OpenAPI-compatible JSON Schema references and inline schemas. - -use std::{ - cell::Cell, - collections::{HashMap, HashSet}, -}; - -use syn::Type; -use vespera_core::schema::{Reference, Schema, SchemaRef, SchemaType}; +//! Type to JSON Schema conversion for OpenAPI generation. -/// Maximum recursion depth for type-to-schema conversion. -/// Prevents stack overflow from deeply nested or circular type references. -const MAX_SCHEMA_RECURSION_DEPTH: usize = 32; - -thread_local! { - static SCHEMA_RECURSION_DEPTH: Cell = const { Cell::new(0) }; -} +mod conversion; -use super::{ - generics::substitute_type, - serde_attrs::{capitalize_first, extract_schema_name_from_entity, extract_schema_ref_override}, - struct_schema::parse_struct_to_schema, +pub use conversion::{ + is_primitive_type, parse_type_to_schema_ref, parse_type_to_schema_ref_with_schemas, }; -/// Check if a type is a primitive Rust type that maps directly to a JSON Schema type. -/// Inline integer schema with an OpenAPI format string. -fn integer_with_format(format: &str) -> SchemaRef { - SchemaRef::Inline(Box::new(Schema { - format: Some(format.to_string()), - ..Schema::integer() - })) -} - -/// Inline number schema with an OpenAPI format string. -fn number_with_format(format: &str) -> SchemaRef { - SchemaRef::Inline(Box::new(Schema { - format: Some(format.to_string()), - ..Schema::number() - })) -} - -/// Inline string schema with an OpenAPI format string. -fn string_with_format(format: &str) -> SchemaRef { - SchemaRef::Inline(Box::new(Schema { - format: Some(format.to_string()), - ..Schema::string() - })) -} - -pub fn is_primitive_type(ty: &Type) -> bool { - match ty { - Type::Path(type_path) => { - let path = &type_path.path; - if path.segments.len() == 1 { - let ident = path.segments[0].ident.to_string(); - ident == "str" - || crate::schema_macro::type_utils::PRIMITIVE_TYPE_NAMES - .contains(&ident.as_str()) - } else { - false - } - } - _ => false, - } -} - -/// Converts a Rust type to an `OpenAPI` `SchemaRef`. -/// -/// This is the main entry point for type-to-schema conversion. -pub fn parse_type_to_schema_ref( - ty: &Type, - known_schemas: &HashSet, - struct_definitions: &HashMap, -) -> SchemaRef { - parse_type_to_schema_ref_with_schemas(ty, known_schemas, struct_definitions) -} - -/// Type-to-schema conversion with depth-guarded recursion. -/// -/// Handles: -/// - Primitive types (i32, String, bool, etc.) -/// - Generic wrappers (Vec, Option, Box) -/// - `SeaORM` relations (`HasOne`, `HasMany`) -/// - Map types (`HashMap`, `BTreeMap`) -/// - Date/time types (`DateTime`, `NaiveDate`, etc.) -/// - Known schema references -/// - Generic type instantiation -pub fn parse_type_to_schema_ref_with_schemas( - ty: &Type, - known_schemas: &HashSet, - struct_definitions: &HashMap, -) -> SchemaRef { - SCHEMA_RECURSION_DEPTH.with(|depth| { - let current = depth.get(); - if current >= MAX_SCHEMA_RECURSION_DEPTH { - return SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))); - } - depth.set(current + 1); - let result = parse_type_impl(ty, known_schemas, struct_definitions); - depth.set(current); - result - }) -} - -/// Core type-to-schema logic (called within depth guard). -#[allow(clippy::too_many_lines)] -fn parse_type_impl( - ty: &Type, - known_schemas: &HashSet, - struct_definitions: &HashMap, -) -> SchemaRef { - match ty { - Type::Path(type_path) => { - let path = &type_path.path; - if path.segments.is_empty() { - return SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))); - } - - // Get the last segment as the type name (handles paths like crate::TestStruct) - let segment = path.segments.last().unwrap(); - let ident_str = segment.ident.to_string(); - - // Handle generic types - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { - match ident_str.as_str() { - // Box -> T's schema (Box is just heap allocation, transparent for schema) - "Box" => { - if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { - return parse_type_to_schema_ref( - inner_ty, - known_schemas, - struct_definitions, - ); - } - } - "Vec" | "HashSet" | "BTreeSet" | "Option" => { - if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { - let inner_schema = parse_type_to_schema_ref( - inner_ty, - known_schemas, - struct_definitions, - ); - if ident_str == "Vec" { - return SchemaRef::Inline(Box::new(Schema::array(inner_schema))); - } - if ident_str == "HashSet" || ident_str == "BTreeSet" { - let mut schema = Schema::array(inner_schema); - schema.unique_items = Some(true); - return SchemaRef::Inline(Box::new(schema)); - } - // Option -> nullable schema - match inner_schema { - SchemaRef::Inline(mut schema) => { - schema.nullable = Some(true); - return SchemaRef::Inline(schema); - } - SchemaRef::Ref(reference) => { - // Wrap reference in an inline schema to attach nullable flag - return SchemaRef::Inline(Box::new(Schema { - ref_path: Some(reference.ref_path), - schema_type: None, - nullable: Some(true), - ..Schema::new(SchemaType::Object) - })); - } - } - } - } - // SeaORM relation types: convert Entity to Schema reference - "HasOne" => { - // HasOne -> nullable reference to corresponding Schema - if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() - && let Some(schema_name) = extract_schema_name_from_entity(inner_ty) - { - return SchemaRef::Inline(Box::new(Schema { - ref_path: Some(format!("#/components/schemas/{schema_name}")), - schema_type: None, - nullable: Some(true), - ..Schema::new(SchemaType::Object) - })); - } - // Fallback: generic object - return SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))); - } - "HasMany" => { - // HasMany -> array of references to corresponding Schema - if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() - && let Some(schema_name) = extract_schema_name_from_entity(inner_ty) - { - let inner_ref = SchemaRef::Ref(Reference::new(format!( - "#/components/schemas/{schema_name}" - ))); - return SchemaRef::Inline(Box::new(Schema::array(inner_ref))); - } - // Fallback: array of generic objects - return SchemaRef::Inline(Box::new(Schema::array(SchemaRef::Inline( - Box::new(Schema::new(SchemaType::Object)), - )))); - } - "HashMap" | "BTreeMap" => { - // HashMap or BTreeMap -> object with additionalProperties - // K is typically String, we use V as the value type - if args.args.len() >= 2 - && let ( - Some(syn::GenericArgument::Type(_key_ty)), - Some(syn::GenericArgument::Type(value_ty)), - ) = (args.args.get(0), args.args.get(1)) - { - let value_schema = parse_type_to_schema_ref( - value_ty, - known_schemas, - struct_definitions, - ); - // Convert SchemaRef to serde_json::Value for additional_properties - let additional_props_value = match value_schema { - SchemaRef::Ref(ref_ref) => { - serde_json::json!({ "$ref": ref_ref.ref_path }) - } - SchemaRef::Inline(schema) => serde_json::to_value(&*schema) - .unwrap_or_else(|_| serde_json::json!({})), - }; - return SchemaRef::Inline(Box::new(Schema { - schema_type: Some(SchemaType::Object), - additional_properties: Some(additional_props_value), - ..Schema::object() - })); - } - } - _ => {} - } - } - - // Handle primitive types - // For standard OpenAPI format types (i32, i64, f32, f64), use `format` - // per the OAS 3.1 Data Type Format spec. For non-standard types, fall - // back to `minimum`/`maximum` constraints. - match ident_str.as_str() { - // Signed integers: use OpenAPI format registry - // https://spec.openapis.org/registry/format/index.html - "i8" => integer_with_format("int8"), - "i16" => integer_with_format("int16"), - "i32" => integer_with_format("int32"), - "i64" => integer_with_format("int64"), - // Unsigned integers: use OpenAPI format registry - "u8" => integer_with_format("uint8"), - "u16" => integer_with_format("uint16"), - "u32" => integer_with_format("uint32"), - "u64" => integer_with_format("uint64"), - // i128, isize, StatusCode: no standard format in the registry - "i128" | "isize" | "StatusCode" => SchemaRef::Inline(Box::new(Schema::integer())), - // u128, usize: unsigned with no standard format — use minimum: 0 - "u128" | "usize" => SchemaRef::Inline(Box::new(Schema { - minimum: Some(0.0), - ..Schema::integer() - })), - "f32" => number_with_format("float"), - "f64" => number_with_format("double"), - "Decimal" => number_with_format("decimal"), - "bool" => SchemaRef::Inline(Box::new(Schema::boolean())), - "char" => string_with_format("char"), - "Uuid" => string_with_format("uuid"), - "String" | "str" => SchemaRef::Inline(Box::new(Schema::string())), - // Date-time types from chrono and time crates - "DateTime" - | "NaiveDateTime" - | "DateTimeWithTimeZone" - | "DateTimeUtc" - | "DateTimeLocal" - | "OffsetDateTime" - | "PrimitiveDateTime" => string_with_format("date-time"), - "NaiveDate" | "Date" => string_with_format("date"), - "NaiveTime" | "Time" => string_with_format("time"), - // Duration types - "Duration" => string_with_format("duration"), - // File upload types (vespera::multipart / tempfile) - // FieldData → string with binary format - "FieldData" | "NamedTempFile" => string_with_format("binary"), - // Standard library types that should not be referenced - // Note: HashMap and BTreeMap are handled above in generic types - "Vec" | "HashSet" | "BTreeSet" | "Option" | "Result" | "Json" | "Path" - | "Query" | "Header" => { - // These are not schema types, return object schema - SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))) - } - _ => { - // Check if this is a known schema (struct with Schema derive) - // Use just the type name (handles both crate::TestStruct and TestStruct) - let type_name = ident_str.clone(); - - // For paths like `module::Schema`, try to find the schema name - // by checking if there's a schema named `ModuleSchema` or `ModuleNameSchema` - let resolved_name = if type_name == "Schema" && path.segments.len() > 1 { - // Get the parent module name (e.g., "user" from "crate::models::user::Schema") - let parent_segment = &path.segments[path.segments.len() - 2]; - let parent_name = parent_segment.ident.to_string(); - - // Try PascalCase version: "user" -> "UserSchema" - // Rust identifiers are guaranteed non-empty - let pascal_name = format!("{}Schema", capitalize_first(&parent_name)); - - if known_schemas.contains(&pascal_name) { - pascal_name - } else { - // Try lowercase version: "userSchema" - let lower_name = format!("{parent_name}Schema"); - if known_schemas.contains(&lower_name) { - lower_name - } else { - type_name - } - } - } else { - type_name - }; - - if known_schemas.contains(&resolved_name) { - if let Some(def) = struct_definitions.get(&resolved_name) - && let Ok(parsed_struct) = syn::parse_str::(def) - && let Some((schema_name, nullable)) = - extract_schema_ref_override(&parsed_struct.attrs) - { - return SchemaRef::Inline(Box::new(Schema { - ref_path: Some(format!("#/components/schemas/{schema_name}")), - schema_type: None, - nullable: nullable.then_some(true), - ..Schema::new(SchemaType::Object) - })); - } - - // Check if this is a generic type with type parameters - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { - // This is a concrete generic type like GenericStruct - // Inline the schema by substituting generic parameters with concrete types - if let Some(base_def) = struct_definitions.get(&resolved_name) - && let Ok(mut parsed) = syn::parse_str::(base_def) - { - // Extract generic parameter names from the struct definition - let generic_params: Vec = parsed - .generics - .params - .iter() - .filter_map(|param| { - if let syn::GenericParam::Type(type_param) = param { - Some(type_param.ident.to_string()) - } else { - None - } - }) - .collect(); - - // Extract concrete type arguments - let concrete_types: Vec<&Type> = args - .args - .iter() - .filter_map(|arg| { - if let syn::GenericArgument::Type(ty) = arg { - Some(ty) - } else { - None - } - }) - .collect(); - - // Substitute generic parameters with concrete types in all fields - if generic_params.len() == concrete_types.len() { - if let syn::Fields::Named(fields_named) = &mut parsed.fields { - for field in &mut fields_named.named { - field.ty = substitute_type( - &field.ty, - &generic_params, - &concrete_types, - ); - } - } - - // Remove generics from the struct (it's now concrete) - parsed.generics.params.clear(); - parsed.generics.where_clause = None; - - // Parse the substituted struct to schema (inline) - let schema = parse_struct_to_schema( - &parsed, - known_schemas, - struct_definitions, - ); - return SchemaRef::Inline(Box::new(schema)); - } - } - } - // Non-generic type or generic without parameters - use reference - SchemaRef::Ref(Reference::schema(&resolved_name)) - } else { - // For unknown custom types, return object schema instead of reference - // This prevents creating invalid references to non-existent schemas - SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))) - } - } - } - } - Type::Reference(type_ref) => { - // Handle &T, &mut T, etc. — goes through depth guard via public entry point - parse_type_to_schema_ref(&type_ref.elem, known_schemas, struct_definitions) - } - // () unit type → null (e.g. Json<()> serializes to JSON null) - Type::Tuple(tuple) if tuple.elems.is_empty() => { - SchemaRef::Inline(Box::new(Schema::new(SchemaType::Null))) - } - _ => SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))), - } -} - #[cfg(test)] mod tests { + use std::collections::{HashMap, HashSet}; + use rstest::rstest; + use syn::Type; + use vespera_core::schema::SchemaRef; use vespera_core::schema::SchemaType; + use super::conversion::{MAX_SCHEMA_RECURSION_DEPTH, SCHEMA_RECURSION_DEPTH}; use super::*; #[rstest] @@ -1193,313 +792,4 @@ mod tests { assert_eq!(depth.get(), 0, "Depth should reset to 0 after call"); }); } - - // ========== Coverage: generic known schema edge cases ========== - - #[test] - fn test_generic_known_schema_no_struct_definition() { - // Known schema with angle brackets but NO struct_definitions entry → falls through to Ref - let mut known = HashSet::new(); - known.insert("Wrapper".to_string()); - // Do NOT insert into struct_definitions - let ty: Type = syn::parse_str("Wrapper").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &known, &HashMap::new()); - // Should fall through to non-generic ref path - assert!( - matches!(schema_ref, SchemaRef::Ref(_)), - "Should be a $ref when no struct definition found" - ); - } - - #[test] - fn test_generic_known_schema_param_count_mismatch() { - // Struct has 1 generic param but 2 concrete types provided → falls through to Ref - let mut known = HashSet::new(); - known.insert("Single".to_string()); - let mut defs = HashMap::new(); - defs.insert( - "Single".to_string(), - "struct Single { value: T }".to_string(), - ); - - let ty: Type = syn::parse_str("Single").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &known, &defs); - assert!( - matches!(schema_ref, SchemaRef::Ref(_)), - "Mismatched param count should fall through to $ref" - ); - } - - #[test] - fn test_generic_known_schema_invalid_definition() { - // struct_definitions has invalid Rust code → parse fails → falls through to Ref - let mut known = HashSet::new(); - known.insert("Bad".to_string()); - let mut defs = HashMap::new(); - defs.insert("Bad".to_string(), "not valid rust code!!!".to_string()); - - let ty: Type = syn::parse_str("Bad").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &known, &defs); - assert!( - matches!(schema_ref, SchemaRef::Ref(_)), - "Invalid definition should fall through to $ref" - ); - } - - #[test] - fn test_generic_known_schema_tuple_struct() { - // Tuple struct fields are NOT Named → skips field substitution but still inlines - let mut known = HashSet::new(); - known.insert("Pair".to_string()); - let mut defs = HashMap::new(); - defs.insert("Pair".to_string(), "struct Pair(T, T);".to_string()); - - let ty: Type = syn::parse_str("Pair").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &known, &defs); - // Tuple struct still gets inlined (generics cleared, parse_struct_to_schema called) - // but field types are NOT substituted (no Named fields to iterate) - assert!( - matches!(schema_ref, SchemaRef::Inline(_)), - "Tuple struct should still inline" - ); - } - - #[test] - fn test_generic_known_schema_no_generic_params_in_def() { - // Struct definition has no generics but concrete type has angle brackets → mismatch - let mut known = HashSet::new(); - known.insert("Plain".to_string()); - let mut defs = HashMap::new(); - defs.insert("Plain".to_string(), "struct Plain { x: i32 }".to_string()); - - let ty: Type = syn::parse_str("Plain").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &known, &defs); - // 0 generic params != 1 concrete type → falls through to Ref - assert!(matches!(schema_ref, SchemaRef::Ref(_))); - } - - // ========== Coverage: nested generic types ========== - - #[test] - fn test_nested_vec_vec_string() { - let ty: Type = syn::parse_str("Vec>").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); - if let SchemaRef::Inline(schema) = &schema_ref { - assert_eq!(schema.schema_type, Some(SchemaType::Array)); - if let Some(SchemaRef::Inline(inner)) = schema.items.as_deref() { - assert_eq!(inner.schema_type, Some(SchemaType::Array)); - if let Some(SchemaRef::Inline(innermost)) = inner.items.as_deref() { - assert_eq!(innermost.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected innermost inline schema"); - } - } else { - panic!("Expected inner inline schema"); - } - } else { - panic!("Expected inline schema for nested Vec"); - } - } - - #[test] - fn test_option_vec_i32() { - let ty: Type = syn::parse_str("Option>").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); - if let SchemaRef::Inline(schema) = &schema_ref { - assert_eq!(schema.schema_type, Some(SchemaType::Array)); - assert_eq!(schema.nullable, Some(true)); - if let Some(SchemaRef::Inline(items)) = schema.items.as_deref() { - assert_eq!(items.schema_type, Some(SchemaType::Integer)); - } else { - panic!("Expected inline items"); - } - } else { - panic!("Expected inline schema for Option>"); - } - } - - #[test] - fn test_box_box_i32() { - // Box> → transparent twice → integer - let ty: Type = syn::parse_str("Box>").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); - if let SchemaRef::Inline(schema) = &schema_ref { - assert_eq!(schema.schema_type, Some(SchemaType::Integer)); - } else { - panic!("Expected inline integer schema for Box>"); - } - } - - // ========== Coverage: HashMap/BTreeMap with known ref value ========== - - #[test] - fn test_hashmap_with_known_ref_value() { - let mut known = HashSet::new(); - known.insert("User".to_string()); - let ty: Type = syn::parse_str("HashMap").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &known, &HashMap::new()); - if let SchemaRef::Inline(schema) = &schema_ref { - assert_eq!(schema.schema_type, Some(SchemaType::Object)); - let additional = schema.additional_properties.as_ref().unwrap(); - assert_eq!(additional.get("$ref").unwrap(), "#/components/schemas/User"); - } else { - panic!("Expected inline schema for HashMap"); - } - } - - #[test] - fn test_btreemap_with_inline_value() { - let ty: Type = syn::parse_str("BTreeMap>").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); - if let SchemaRef::Inline(schema) = &schema_ref { - assert_eq!(schema.schema_type, Some(SchemaType::Object)); - let additional = schema.additional_properties.as_ref().unwrap(); - // Value should be an array schema serialized - assert_eq!(additional.get("type").unwrap(), "array"); - } else { - panic!("Expected inline schema for BTreeMap with Vec value"); - } - } - - // ========== Coverage: HashMap/BTreeMap with insufficient args ========== - - #[test] - fn test_hashmap_single_arg_falls_through() { - // HashMap — only 1 type arg, need 2 → falls through to unknown type - let ty: Type = syn::parse_str("HashMap").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); - if let SchemaRef::Inline(schema) = &schema_ref { - assert_eq!(schema.schema_type, Some(SchemaType::Object)); - // Should NOT have additional_properties since it fell through - assert!(schema.additional_properties.is_none()); - } else { - panic!("Expected inline schema"); - } - } - - // ========== Coverage: &mut T reference ========== - - #[test] - fn test_mutable_reference_delegates_to_inner() { - let ty: Type = syn::parse_str("&mut String").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); - if let SchemaRef::Inline(schema) = &schema_ref { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline string schema for &mut String"); - } - } - - // ========== Coverage: HashSet/BTreeSet → uniqueItems ========== - - #[test] - fn test_hashset_string_produces_unique_items_array() { - let ty: Type = syn::parse_str("HashSet").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); - if let SchemaRef::Inline(schema) = &schema_ref { - assert_eq!(schema.schema_type, Some(SchemaType::Array)); - assert_eq!(schema.unique_items, Some(true)); - if let Some(SchemaRef::Inline(items)) = schema.items.as_deref() { - assert_eq!(items.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline string items for HashSet"); - } - } else { - panic!("Expected inline schema for HashSet"); - } - } - - #[test] - fn test_btreeset_i32_produces_unique_items_array() { - let ty: Type = syn::parse_str("BTreeSet").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); - if let SchemaRef::Inline(schema) = &schema_ref { - assert_eq!(schema.schema_type, Some(SchemaType::Array)); - assert_eq!(schema.unique_items, Some(true)); - if let Some(SchemaRef::Inline(items)) = schema.items.as_deref() { - assert_eq!(items.schema_type, Some(SchemaType::Integer)); - } else { - panic!("Expected inline integer items for BTreeSet"); - } - } else { - panic!("Expected inline schema for BTreeSet"); - } - } - - #[test] - fn test_option_hashset_is_nullable_unique_array() { - let ty: Type = syn::parse_str("Option>").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); - if let SchemaRef::Inline(schema) = &schema_ref { - assert_eq!(schema.schema_type, Some(SchemaType::Array)); - assert_eq!(schema.unique_items, Some(true)); - assert_eq!(schema.nullable, Some(true)); - if let Some(SchemaRef::Inline(items)) = schema.items.as_deref() { - assert_eq!(items.schema_type, Some(SchemaType::Integer)); - } else { - panic!("Expected inline integer items for Option>"); - } - } else { - panic!("Expected inline schema for Option>"); - } - } - - #[test] - fn test_vec_does_not_have_unique_items() { - let ty: Type = syn::parse_str("Vec").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); - if let SchemaRef::Inline(schema) = &schema_ref { - assert_eq!(schema.schema_type, Some(SchemaType::Array)); - assert!(schema.unique_items.is_none()); - } else { - panic!("Expected inline schema for Vec"); - } - } - - #[test] - fn test_bare_hashset_without_generics() { - // HashSet without angle brackets → falls through to bare-name match - let ty: Type = syn::parse_str("HashSet").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); - assert!(matches!(schema_ref, SchemaRef::Inline(_))); - } - - #[test] - fn test_bare_btreeset_without_generics() { - let ty: Type = syn::parse_str("BTreeSet").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); - assert!(matches!(schema_ref, SchemaRef::Inline(_))); - } - - #[test] - fn test_known_schema_ref_override_returns_inline_ref_schema() { - let mut known = HashSet::new(); - known.insert("UserSchema".to_string()); - - let mut defs = HashMap::new(); - defs.insert( - "UserSchema".to_string(), - r#" - #[schema(ref = "ExternalUser", nullable)] - struct UserSchema { - id: i32, - } - "# - .to_string(), - ); - - let ty: Type = syn::parse_str("UserSchema").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &known, &defs); - - match schema_ref { - SchemaRef::Inline(schema) => { - assert_eq!( - schema.ref_path.as_deref(), - Some("#/components/schemas/ExternalUser") - ); - assert_eq!(schema.nullable, Some(true)); - } - SchemaRef::Ref(_) => panic!("expected inline schema ref override"), - } - } } diff --git a/crates/vespera_macro/src/parser/schema/type_schema/conversion.rs b/crates/vespera_macro/src/parser/schema/type_schema/conversion.rs new file mode 100644 index 00000000..a070fcd7 --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/type_schema/conversion.rs @@ -0,0 +1,726 @@ +//! Type to JSON Schema conversion for `OpenAPI` generation. +//! +//! This module handles the conversion of Rust types (as parsed by syn) +//! into OpenAPI-compatible JSON Schema references and inline schemas. + +use std::{ + cell::Cell, + collections::{HashMap, HashSet}, +}; + +use syn::Type; +use vespera_core::schema::{Reference, Schema, SchemaRef, SchemaType}; + +/// Maximum recursion depth for type-to-schema conversion. +/// Prevents stack overflow from deeply nested or circular type references. +pub(super) const MAX_SCHEMA_RECURSION_DEPTH: usize = 32; + +thread_local! { + pub(super) static SCHEMA_RECURSION_DEPTH: Cell = const { Cell::new(0) }; +} + +use super::super::{ + generics::substitute_type, + serde_attrs::{capitalize_first, extract_schema_name_from_entity, extract_schema_ref_override}, + struct_schema::parse_struct_to_schema, +}; + +/// Check if a type is a primitive Rust type that maps directly to a JSON Schema type. +/// Inline integer schema with an OpenAPI format string. +fn integer_with_format(format: &str) -> SchemaRef { + SchemaRef::Inline(Box::new(Schema { + format: Some(format.to_string()), + ..Schema::integer() + })) +} + +/// Inline number schema with an OpenAPI format string. +fn number_with_format(format: &str) -> SchemaRef { + SchemaRef::Inline(Box::new(Schema { + format: Some(format.to_string()), + ..Schema::number() + })) +} + +/// Inline string schema with an OpenAPI format string. +fn string_with_format(format: &str) -> SchemaRef { + SchemaRef::Inline(Box::new(Schema { + format: Some(format.to_string()), + ..Schema::string() + })) +} + +pub fn is_primitive_type(ty: &Type) -> bool { + match ty { + Type::Path(type_path) => { + let path = &type_path.path; + if path.segments.len() == 1 { + let ident = path.segments[0].ident.to_string(); + ident == "str" + || crate::schema_macro::type_utils::PRIMITIVE_TYPE_NAMES + .contains(&ident.as_str()) + } else { + false + } + } + _ => false, + } +} + +/// Converts a Rust type to an `OpenAPI` `SchemaRef`. +/// +/// This is the main entry point for type-to-schema conversion. +pub fn parse_type_to_schema_ref( + ty: &Type, + known_schemas: &HashSet, + struct_definitions: &HashMap, +) -> SchemaRef { + parse_type_to_schema_ref_with_schemas(ty, known_schemas, struct_definitions) +} + +/// Type-to-schema conversion with depth-guarded recursion. +/// +/// Handles: +/// - Primitive types (i32, String, bool, etc.) +/// - Generic wrappers (Vec, Option, Box) +/// - `SeaORM` relations (`HasOne`, `HasMany`) +/// - Map types (`HashMap`, `BTreeMap`) +/// - Date/time types (`DateTime`, `NaiveDate`, etc.) +/// - Known schema references +/// - Generic type instantiation +pub fn parse_type_to_schema_ref_with_schemas( + ty: &Type, + known_schemas: &HashSet, + struct_definitions: &HashMap, +) -> SchemaRef { + SCHEMA_RECURSION_DEPTH.with(|depth| { + let current = depth.get(); + if current >= MAX_SCHEMA_RECURSION_DEPTH { + return SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))); + } + depth.set(current + 1); + let result = parse_type_impl(ty, known_schemas, struct_definitions); + depth.set(current); + result + }) +} + +/// Core type-to-schema logic (called within depth guard). +#[allow(clippy::too_many_lines)] +fn parse_type_impl( + ty: &Type, + known_schemas: &HashSet, + struct_definitions: &HashMap, +) -> SchemaRef { + match ty { + Type::Path(type_path) => { + let path = &type_path.path; + if path.segments.is_empty() { + return SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))); + } + + // Get the last segment as the type name (handles paths like crate::TestStruct) + let segment = path.segments.last().unwrap(); + let ident_str = segment.ident.to_string(); + + // Handle generic types + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + match ident_str.as_str() { + // Box -> T's schema (Box is just heap allocation, transparent for schema) + "Box" => { + if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { + return parse_type_to_schema_ref( + inner_ty, + known_schemas, + struct_definitions, + ); + } + } + "Vec" | "HashSet" | "BTreeSet" | "Option" => { + if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { + let inner_schema = parse_type_to_schema_ref( + inner_ty, + known_schemas, + struct_definitions, + ); + if ident_str == "Vec" { + return SchemaRef::Inline(Box::new(Schema::array(inner_schema))); + } + if ident_str == "HashSet" || ident_str == "BTreeSet" { + let mut schema = Schema::array(inner_schema); + schema.unique_items = Some(true); + return SchemaRef::Inline(Box::new(schema)); + } + // Option -> nullable schema + match inner_schema { + SchemaRef::Inline(mut schema) => { + schema.nullable = Some(true); + return SchemaRef::Inline(schema); + } + SchemaRef::Ref(reference) => { + // Wrap reference in an inline schema to attach nullable flag + return SchemaRef::Inline(Box::new(Schema { + ref_path: Some(reference.ref_path), + schema_type: None, + nullable: Some(true), + ..Schema::new(SchemaType::Object) + })); + } + } + } + } + // SeaORM relation types: convert Entity to Schema reference + "HasOne" => { + // HasOne -> nullable reference to corresponding Schema + if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() + && let Some(schema_name) = extract_schema_name_from_entity(inner_ty) + { + return SchemaRef::Inline(Box::new(Schema { + ref_path: Some(format!("#/components/schemas/{schema_name}")), + schema_type: None, + nullable: Some(true), + ..Schema::new(SchemaType::Object) + })); + } + // Fallback: generic object + return SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))); + } + "HasMany" => { + // HasMany -> array of references to corresponding Schema + if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() + && let Some(schema_name) = extract_schema_name_from_entity(inner_ty) + { + let inner_ref = SchemaRef::Ref(Reference::new(format!( + "#/components/schemas/{schema_name}" + ))); + return SchemaRef::Inline(Box::new(Schema::array(inner_ref))); + } + // Fallback: array of generic objects + return SchemaRef::Inline(Box::new(Schema::array(SchemaRef::Inline( + Box::new(Schema::new(SchemaType::Object)), + )))); + } + "HashMap" | "BTreeMap" => { + // HashMap or BTreeMap -> object with additionalProperties + // K is typically String, we use V as the value type + if args.args.len() >= 2 + && let ( + Some(syn::GenericArgument::Type(_key_ty)), + Some(syn::GenericArgument::Type(value_ty)), + ) = (args.args.get(0), args.args.get(1)) + { + let value_schema = parse_type_to_schema_ref( + value_ty, + known_schemas, + struct_definitions, + ); + // Convert SchemaRef to serde_json::Value for additional_properties + let additional_props_value = match value_schema { + SchemaRef::Ref(ref_ref) => { + serde_json::json!({ "$ref": ref_ref.ref_path }) + } + SchemaRef::Inline(schema) => serde_json::to_value(&*schema) + .unwrap_or_else(|_| serde_json::json!({})), + }; + return SchemaRef::Inline(Box::new(Schema { + schema_type: Some(SchemaType::Object), + additional_properties: Some(additional_props_value), + ..Schema::object() + })); + } + } + _ => {} + } + } + + // Handle primitive types + // For standard OpenAPI format types (i32, i64, f32, f64), use `format` + // per the OAS 3.1 Data Type Format spec. For non-standard types, fall + // back to `minimum`/`maximum` constraints. + match ident_str.as_str() { + // Signed integers: use OpenAPI format registry + // https://spec.openapis.org/registry/format/index.html + "i8" => integer_with_format("int8"), + "i16" => integer_with_format("int16"), + "i32" => integer_with_format("int32"), + "i64" => integer_with_format("int64"), + // Unsigned integers: use OpenAPI format registry + "u8" => integer_with_format("uint8"), + "u16" => integer_with_format("uint16"), + "u32" => integer_with_format("uint32"), + "u64" => integer_with_format("uint64"), + // i128, isize, StatusCode: no standard format in the registry + "i128" | "isize" | "StatusCode" => SchemaRef::Inline(Box::new(Schema::integer())), + // u128, usize: unsigned with no standard format — use minimum: 0 + "u128" | "usize" => SchemaRef::Inline(Box::new(Schema { + minimum: Some(0.0), + ..Schema::integer() + })), + "f32" => number_with_format("float"), + "f64" => number_with_format("double"), + "Decimal" => number_with_format("decimal"), + "bool" => SchemaRef::Inline(Box::new(Schema::boolean())), + "char" => string_with_format("char"), + "Uuid" => string_with_format("uuid"), + "String" | "str" => SchemaRef::Inline(Box::new(Schema::string())), + // Date-time types from chrono and time crates + "DateTime" + | "NaiveDateTime" + | "DateTimeWithTimeZone" + | "DateTimeUtc" + | "DateTimeLocal" + | "OffsetDateTime" + | "PrimitiveDateTime" => string_with_format("date-time"), + "NaiveDate" | "Date" => string_with_format("date"), + "NaiveTime" | "Time" => string_with_format("time"), + // Duration types + "Duration" => string_with_format("duration"), + // File upload types (vespera::multipart / tempfile) + // FieldData → string with binary format + "FieldData" | "NamedTempFile" => string_with_format("binary"), + // Standard library types that should not be referenced + // Note: HashMap and BTreeMap are handled above in generic types + "Vec" | "HashSet" | "BTreeSet" | "Option" | "Result" | "Json" | "Path" + | "Query" | "Header" => { + // These are not schema types, return object schema + SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))) + } + _ => { + // Check if this is a known schema (struct with Schema derive) + // Use just the type name (handles both crate::TestStruct and TestStruct) + let type_name = ident_str.clone(); + + // For paths like `module::Schema`, try to find the schema name + // by checking if there's a schema named `ModuleSchema` or `ModuleNameSchema` + let resolved_name = if type_name == "Schema" && path.segments.len() > 1 { + // Get the parent module name (e.g., "user" from "crate::models::user::Schema") + let parent_segment = &path.segments[path.segments.len() - 2]; + let parent_name = parent_segment.ident.to_string(); + + // Try PascalCase version: "user" -> "UserSchema" + // Rust identifiers are guaranteed non-empty + let pascal_name = format!("{}Schema", capitalize_first(&parent_name)); + + if known_schemas.contains(&pascal_name) { + pascal_name + } else { + // Try lowercase version: "userSchema" + let lower_name = format!("{parent_name}Schema"); + if known_schemas.contains(&lower_name) { + lower_name + } else { + type_name + } + } + } else { + type_name + }; + + if known_schemas.contains(&resolved_name) { + if let Some(def) = struct_definitions.get(&resolved_name) + && let Ok(parsed_struct) = syn::parse_str::(def) + && let Some((schema_name, nullable)) = + extract_schema_ref_override(&parsed_struct.attrs) + { + return SchemaRef::Inline(Box::new(Schema { + ref_path: Some(format!("#/components/schemas/{schema_name}")), + schema_type: None, + nullable: nullable.then_some(true), + ..Schema::new(SchemaType::Object) + })); + } + + // Check if this is a generic type with type parameters + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + // This is a concrete generic type like GenericStruct + // Inline the schema by substituting generic parameters with concrete types + if let Some(base_def) = struct_definitions.get(&resolved_name) + && let Ok(mut parsed) = syn::parse_str::(base_def) + { + // Extract generic parameter names from the struct definition + let generic_params: Vec = parsed + .generics + .params + .iter() + .filter_map(|param| { + if let syn::GenericParam::Type(type_param) = param { + Some(type_param.ident.to_string()) + } else { + None + } + }) + .collect(); + + // Extract concrete type arguments + let concrete_types: Vec<&Type> = args + .args + .iter() + .filter_map(|arg| { + if let syn::GenericArgument::Type(ty) = arg { + Some(ty) + } else { + None + } + }) + .collect(); + + // Substitute generic parameters with concrete types in all fields + if generic_params.len() == concrete_types.len() { + if let syn::Fields::Named(fields_named) = &mut parsed.fields { + for field in &mut fields_named.named { + field.ty = substitute_type( + &field.ty, + &generic_params, + &concrete_types, + ); + } + } + + // Remove generics from the struct (it's now concrete) + parsed.generics.params.clear(); + parsed.generics.where_clause = None; + + // Parse the substituted struct to schema (inline) + let schema = parse_struct_to_schema( + &parsed, + known_schemas, + struct_definitions, + ); + return SchemaRef::Inline(Box::new(schema)); + } + } + } + // Non-generic type or generic without parameters - use reference + SchemaRef::Ref(Reference::schema(&resolved_name)) + } else { + // For unknown custom types, return object schema instead of reference + // This prevents creating invalid references to non-existent schemas + SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))) + } + } + } + } + Type::Reference(type_ref) => { + // Handle &T, &mut T, etc. — goes through depth guard via public entry point + parse_type_to_schema_ref(&type_ref.elem, known_schemas, struct_definitions) + } + // () unit type → null (e.g. Json<()> serializes to JSON null) + Type::Tuple(tuple) if tuple.elems.is_empty() => { + SchemaRef::Inline(Box::new(Schema::new(SchemaType::Null))) + } + _ => SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + // ========== Coverage: generic known schema edge cases ========== + + #[test] + fn test_generic_known_schema_no_struct_definition() { + // Known schema with angle brackets but NO struct_definitions entry → falls through to Ref + let mut known = HashSet::new(); + known.insert("Wrapper".to_string()); + // Do NOT insert into struct_definitions + let ty: Type = syn::parse_str("Wrapper").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &known, &HashMap::new()); + // Should fall through to non-generic ref path + assert!( + matches!(schema_ref, SchemaRef::Ref(_)), + "Should be a $ref when no struct definition found" + ); + } + + #[test] + fn test_generic_known_schema_param_count_mismatch() { + // Struct has 1 generic param but 2 concrete types provided → falls through to Ref + let mut known = HashSet::new(); + known.insert("Single".to_string()); + let mut defs = HashMap::new(); + defs.insert( + "Single".to_string(), + "struct Single { value: T }".to_string(), + ); + + let ty: Type = syn::parse_str("Single").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &known, &defs); + assert!( + matches!(schema_ref, SchemaRef::Ref(_)), + "Mismatched param count should fall through to $ref" + ); + } + + #[test] + fn test_generic_known_schema_invalid_definition() { + // struct_definitions has invalid Rust code → parse fails → falls through to Ref + let mut known = HashSet::new(); + known.insert("Bad".to_string()); + let mut defs = HashMap::new(); + defs.insert("Bad".to_string(), "not valid rust code!!!".to_string()); + + let ty: Type = syn::parse_str("Bad").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &known, &defs); + assert!( + matches!(schema_ref, SchemaRef::Ref(_)), + "Invalid definition should fall through to $ref" + ); + } + + #[test] + fn test_generic_known_schema_tuple_struct() { + // Tuple struct fields are NOT Named → skips field substitution but still inlines + let mut known = HashSet::new(); + known.insert("Pair".to_string()); + let mut defs = HashMap::new(); + defs.insert("Pair".to_string(), "struct Pair(T, T);".to_string()); + + let ty: Type = syn::parse_str("Pair").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &known, &defs); + // Tuple struct still gets inlined (generics cleared, parse_struct_to_schema called) + // but field types are NOT substituted (no Named fields to iterate) + assert!( + matches!(schema_ref, SchemaRef::Inline(_)), + "Tuple struct should still inline" + ); + } + + #[test] + fn test_generic_known_schema_no_generic_params_in_def() { + // Struct definition has no generics but concrete type has angle brackets → mismatch + let mut known = HashSet::new(); + known.insert("Plain".to_string()); + let mut defs = HashMap::new(); + defs.insert("Plain".to_string(), "struct Plain { x: i32 }".to_string()); + + let ty: Type = syn::parse_str("Plain").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &known, &defs); + // 0 generic params != 1 concrete type → falls through to Ref + assert!(matches!(schema_ref, SchemaRef::Ref(_))); + } + + // ========== Coverage: nested generic types ========== + + #[test] + fn test_nested_vec_vec_string() { + let ty: Type = syn::parse_str("Vec>").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Array)); + if let Some(SchemaRef::Inline(inner)) = schema.items.as_deref() { + assert_eq!(inner.schema_type, Some(SchemaType::Array)); + if let Some(SchemaRef::Inline(innermost)) = inner.items.as_deref() { + assert_eq!(innermost.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected innermost inline schema"); + } + } else { + panic!("Expected inner inline schema"); + } + } else { + panic!("Expected inline schema for nested Vec"); + } + } + + #[test] + fn test_option_vec_i32() { + let ty: Type = syn::parse_str("Option>").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Array)); + assert_eq!(schema.nullable, Some(true)); + if let Some(SchemaRef::Inline(items)) = schema.items.as_deref() { + assert_eq!(items.schema_type, Some(SchemaType::Integer)); + } else { + panic!("Expected inline items"); + } + } else { + panic!("Expected inline schema for Option>"); + } + } + + #[test] + fn test_box_box_i32() { + // Box> → transparent twice → integer + let ty: Type = syn::parse_str("Box>").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Integer)); + } else { + panic!("Expected inline integer schema for Box>"); + } + } + + // ========== Coverage: HashMap/BTreeMap with known ref value ========== + + #[test] + fn test_hashmap_with_known_ref_value() { + let mut known = HashSet::new(); + known.insert("User".to_string()); + let ty: Type = syn::parse_str("HashMap").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &known, &HashMap::new()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Object)); + let additional = schema.additional_properties.as_ref().unwrap(); + assert_eq!(additional.get("$ref").unwrap(), "#/components/schemas/User"); + } else { + panic!("Expected inline schema for HashMap"); + } + } + + #[test] + fn test_btreemap_with_inline_value() { + let ty: Type = syn::parse_str("BTreeMap>").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Object)); + let additional = schema.additional_properties.as_ref().unwrap(); + // Value should be an array schema serialized + assert_eq!(additional.get("type").unwrap(), "array"); + } else { + panic!("Expected inline schema for BTreeMap with Vec value"); + } + } + + // ========== Coverage: HashMap/BTreeMap with insufficient args ========== + + #[test] + fn test_hashmap_single_arg_falls_through() { + // HashMap — only 1 type arg, need 2 → falls through to unknown type + let ty: Type = syn::parse_str("HashMap").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Object)); + // Should NOT have additional_properties since it fell through + assert!(schema.additional_properties.is_none()); + } else { + panic!("Expected inline schema"); + } + } + + // ========== Coverage: &mut T reference ========== + + #[test] + fn test_mutable_reference_delegates_to_inner() { + let ty: Type = syn::parse_str("&mut String").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected inline string schema for &mut String"); + } + } + + // ========== Coverage: HashSet/BTreeSet → uniqueItems ========== + + #[test] + fn test_hashset_string_produces_unique_items_array() { + let ty: Type = syn::parse_str("HashSet").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Array)); + assert_eq!(schema.unique_items, Some(true)); + if let Some(SchemaRef::Inline(items)) = schema.items.as_deref() { + assert_eq!(items.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected inline string items for HashSet"); + } + } else { + panic!("Expected inline schema for HashSet"); + } + } + + #[test] + fn test_btreeset_i32_produces_unique_items_array() { + let ty: Type = syn::parse_str("BTreeSet").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Array)); + assert_eq!(schema.unique_items, Some(true)); + if let Some(SchemaRef::Inline(items)) = schema.items.as_deref() { + assert_eq!(items.schema_type, Some(SchemaType::Integer)); + } else { + panic!("Expected inline integer items for BTreeSet"); + } + } else { + panic!("Expected inline schema for BTreeSet"); + } + } + + #[test] + fn test_option_hashset_is_nullable_unique_array() { + let ty: Type = syn::parse_str("Option>").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Array)); + assert_eq!(schema.unique_items, Some(true)); + assert_eq!(schema.nullable, Some(true)); + if let Some(SchemaRef::Inline(items)) = schema.items.as_deref() { + assert_eq!(items.schema_type, Some(SchemaType::Integer)); + } else { + panic!("Expected inline integer items for Option>"); + } + } else { + panic!("Expected inline schema for Option>"); + } + } + + #[test] + fn test_vec_does_not_have_unique_items() { + let ty: Type = syn::parse_str("Vec").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Array)); + assert!(schema.unique_items.is_none()); + } else { + panic!("Expected inline schema for Vec"); + } + } + + #[test] + fn test_bare_hashset_without_generics() { + // HashSet without angle brackets → falls through to bare-name match + let ty: Type = syn::parse_str("HashSet").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + assert!(matches!(schema_ref, SchemaRef::Inline(_))); + } + + #[test] + fn test_bare_btreeset_without_generics() { + let ty: Type = syn::parse_str("BTreeSet").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + assert!(matches!(schema_ref, SchemaRef::Inline(_))); + } + + #[test] + fn test_known_schema_ref_override_returns_inline_ref_schema() { + let mut known = HashSet::new(); + known.insert("UserSchema".to_string()); + + let mut defs = HashMap::new(); + defs.insert( + "UserSchema".to_string(), + r#" + #[schema(ref = "ExternalUser", nullable)] + struct UserSchema { + id: i32, + } + "# + .to_string(), + ); + + let ty: Type = syn::parse_str("UserSchema").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &known, &defs); + + match schema_ref { + SchemaRef::Inline(schema) => { + assert_eq!( + schema.ref_path.as_deref(), + Some("#/components/schemas/ExternalUser") + ); + assert_eq!(schema.nullable, Some(true)); + } + SchemaRef::Ref(_) => panic!("expected inline schema ref override"), + } + } +} diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_named_object.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_named_object.snap deleted file mode 100644 index 77851e77..00000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_named_object.snap +++ /dev/null @@ -1,239 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/schema.rs -expression: schema ---- -Schema { - ref_path: None, - schema_type: None, - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: Some( - [ - Inline( - Schema { - ref_path: None, - schema_type: Some( - Object, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: Some( - { - "Detail": Inline( - Schema { - ref_path: None, - schema_type: Some( - Object, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: Some( - { - "id": Inline( - Schema { - ref_path: None, - schema_type: Some( - Integer, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - "note": Inline( - Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: Some( - true, - ), - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - }, - ), - required: Some( - [ - "id", - ], - ), - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - }, - ), - required: Some( - [ - "Detail", - ], - ), - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ], - ), - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, -} diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_tuple_multi.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_tuple_multi.snap deleted file mode 100644 index 7c87eba8..00000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_tuple_multi.snap +++ /dev/null @@ -1,237 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/schema.rs -expression: schema ---- -Schema { - ref_path: None, - schema_type: None, - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: Some( - [ - Inline( - Schema { - ref_path: None, - schema_type: Some( - Object, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: Some( - { - "Values": Inline( - Schema { - ref_path: None, - schema_type: Some( - Array, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: Some( - [ - Inline( - Schema { - ref_path: None, - schema_type: Some( - Integer, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - Inline( - Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ], - ), - min_items: Some( - 2, - ), - max_items: Some( - 2, - ), - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - }, - ), - required: Some( - [ - "Values", - ], - ), - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ], - ), - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, -} diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_tuple_single.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_tuple_single.snap deleted file mode 100644 index 16038cc3..00000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_tuple_single.snap +++ /dev/null @@ -1,142 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/schema.rs -expression: schema ---- -Schema { - ref_path: None, - schema_type: None, - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: Some( - [ - Inline( - Schema { - ref_path: None, - schema_type: Some( - Object, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: Some( - { - "Data": Inline( - Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - }, - ), - required: Some( - [ - "Data", - ], - ), - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ], - ), - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, -} diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_simple.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_simple.snap deleted file mode 100644 index 933db19a..00000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_simple.snap +++ /dev/null @@ -1,51 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/schema.rs -expression: schema ---- -Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: Some( - [ - String("First"), - String("Second"), - ], - ), - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, -} diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_simple_snake.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_simple_snake.snap deleted file mode 100644 index 07da71c1..00000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_simple_snake.snap +++ /dev/null @@ -1,51 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/schema.rs -expression: schema ---- -Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: Some( - [ - String("first_item"), - String("second_item"), - ], - ), - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, -} diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_status.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_status.snap deleted file mode 100644 index e42df388..00000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_status.snap +++ /dev/null @@ -1,51 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/schema.rs -expression: schema ---- -Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: Some( - [ - String("ok-status"), - String("error-code"), - ], - ), - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, -} diff --git a/crates/vespera_macro/src/router_codegen.rs b/crates/vespera_macro/src/router_codegen.rs index 75f60129..b1629a16 100644 --- a/crates/vespera_macro/src/router_codegen.rs +++ b/crates/vespera_macro/src/router_codegen.rs @@ -1,1970 +1,13 @@ //! Router code generation and macro input parsing. //! -//! This module contains the core logic for: -//! - Parsing `vespera!` and `export_app!` macro inputs -//! - Processing input into validated configuration -//! - Generating Axum router code from collected metadata -//! -//! # Overview -//! -//! The vespera macros accept configuration parameters (directory, `OpenAPI` files, etc.) -//! which are parsed and processed into a normalized form. This module then generates -//! the `TokenStream` that creates the Axum router with all discovered routes. -//! -//! # Key Components -//! -//! - [`AutoRouterInput`] - Parsed `vespera!()` macro arguments -//! - [`ExportAppInput`] - Parsed `export_app!()` macro arguments -//! - [`process_vespera_input`] - Validate and process vespera! arguments -//! - [`generate_router_code`] - Generate the router `TokenStream` -//! -//! # Macro Parameters -//! -//! **vespera!()** accepts: -//! - `dir` - Route discovery folder (default: "routes") -//! - `openapi` - Output file path(s) for `OpenAPI` spec -//! - `title` - API title (`OpenAPI` info.title) -//! - `version` - API version (`OpenAPI` info.version) -//! - `docs_url` - Swagger UI endpoint -//! - `redoc_url` - `ReDoc` endpoint -//! - `servers` - Array of server configurations -//! - `merge` - Child vespera apps to merge -//! -//! **`export_app`!()** accepts: -//! - `dir` - Route discovery folder (default: "routes") - -use proc_macro2::Span; -use quote::quote; -use syn::{ - LitStr, bracketed, - parse::{Parse, ParseStream}, - punctuated::Punctuated, -}; -use vespera_core::{openapi::Server, route::HttpMethod}; - -use crate::{ - metadata::{CollectedMetadata, CronMetadata}, - method::http_method_to_token_stream, -}; - -/// Server configuration for `OpenAPI` -#[derive(Clone)] -pub struct ServerConfig { - pub url: String, - pub description: Option, -} - -/// Input for the `vespera!` macro -pub struct AutoRouterInput { - pub dir: Option, - pub openapi: Option>, - pub title: Option, - pub version: Option, - pub docs_url: Option, - pub redoc_url: Option, - pub servers: Option>, - /// Apps to merge (e.g., [`third::ThirdApp`, `another::AnotherApp`]) - pub merge: Option>, -} - -impl Parse for AutoRouterInput { - #[allow(clippy::too_many_lines)] - fn parse(input: ParseStream) -> syn::Result { - let mut dir = None; - let mut openapi = None; - let mut title = None; - let mut version = None; - let mut docs_url = None; - let mut redoc_url = None; - let mut servers = None; - let mut merge = None; - - while !input.is_empty() { - let lookahead = input.lookahead1(); - - if lookahead.peek(syn::Ident) { - let ident: syn::Ident = input.parse()?; - let ident_str = ident.to_string(); - - match ident_str.as_str() { - "dir" => { - input.parse::()?; - dir = Some(input.parse()?); - } - "openapi" => { - openapi = Some(parse_openapi_values(input)?); - } - "docs_url" => { - input.parse::()?; - docs_url = Some(input.parse()?); - } - "redoc_url" => { - input.parse::()?; - redoc_url = Some(input.parse()?); - } - "title" => { - input.parse::()?; - title = Some(input.parse()?); - } - "version" => { - input.parse::()?; - version = Some(input.parse()?); - } - "servers" => { - servers = Some(parse_servers_values(input)?); - } - "merge" => { - merge = Some(parse_merge_values(input)?); - } - _ => { - return Err(syn::Error::new( - ident.span(), - format!( - "unknown field: `{ident_str}`. Expected `dir`, `openapi`, `title`, `version`, `docs_url`, `redoc_url`, `servers`, or `merge`" - ), - )); - } - } - } else if lookahead.peek(syn::LitStr) { - // If just a string, treat it as dir (for backward compatibility) - dir = Some(input.parse()?); - } else { - return Err(lookahead.error()); - } - - if input.peek(syn::Token![,]) { - input.parse::()?; - } else { - break; - } - } - - Ok(Self { - dir: dir.or_else(|| { - std::env::var("VESPERA_DIR") - .map(|f| LitStr::new(&f, Span::call_site())) - .ok() - }), - openapi: openapi.or_else(|| { - std::env::var("VESPERA_OPENAPI") - .map(|f| vec![LitStr::new(&f, Span::call_site())]) - .ok() - }), - title: title.or_else(|| { - std::env::var("VESPERA_TITLE") - .map(|f| LitStr::new(&f, Span::call_site())) - .ok() - }), - version: version - .or_else(|| { - std::env::var("VESPERA_VERSION") - .map(|f| LitStr::new(&f, Span::call_site())) - .ok() - }) - .or_else(|| { - std::env::var("CARGO_PKG_VERSION") - .map(|f| LitStr::new(&f, Span::call_site())) - .ok() - }), - docs_url: docs_url.or_else(|| { - std::env::var("VESPERA_DOCS_URL") - .map(|f| LitStr::new(&f, Span::call_site())) - .ok() - }), - redoc_url: redoc_url.or_else(|| { - std::env::var("VESPERA_REDOC_URL") - .map(|f| LitStr::new(&f, Span::call_site())) - .ok() - }), - servers: servers.or_else(|| { - std::env::var("VESPERA_SERVER_URL") - .ok() - .filter(|url| url.starts_with("http://") || url.starts_with("https://")) - .map(|url| { - vec![ServerConfig { - url, - description: std::env::var("VESPERA_SERVER_DESCRIPTION").ok(), - }] - }) - }), - merge, - }) - } -} - -/// Parse merge values: merge = [`path::to::App`, `another::App`] -fn parse_merge_values(input: ParseStream) -> syn::Result> { - input.parse::()?; - - let content; - let _ = bracketed!(content in input); - let paths: Punctuated = - content.parse_terminated(syn::Path::parse, syn::Token![,])?; - Ok(paths.into_iter().collect()) -} - -fn parse_openapi_values(input: ParseStream) -> syn::Result> { - input.parse::()?; - - if input.peek(syn::token::Bracket) { - let content; - let _ = bracketed!(content in input); - let entries: Punctuated = - content.parse_terminated(syn::parse::ParseBuffer::parse::, syn::Token![,])?; - Ok(entries.into_iter().collect()) - } else { - let single: LitStr = input.parse()?; - Ok(vec![single]) - } -} - -/// Validate that a URL starts with http:// or https:// -fn validate_server_url(url: &LitStr) -> syn::Result { - let url_value = url.value(); - if !url_value.starts_with("http://") && !url_value.starts_with("https://") { - return Err(syn::Error::new( - url.span(), - format!( - "invalid server URL: `{url_value}`. URL must start with `http://` or `https://`" - ), - )); - } - Ok(url_value) -} - -/// Parse server values in various formats: -/// - `servers = "url"` - single URL -/// - `servers = ["url1", "url2"]` - multiple URLs (strings only) -/// - `servers = [("url", "description")]` - tuple format with descriptions -/// - `servers = [{url = "...", description = "..."}]` - struct-like format -/// - `servers = {url = "...", description = "..."}` - single server struct-like format -fn parse_servers_values(input: ParseStream) -> syn::Result> { - use syn::token::{Brace, Paren}; - - input.parse::()?; - - if input.peek(syn::token::Bracket) { - // Array format: [...] - let content; - let _ = bracketed!(content in input); - - let mut servers = Vec::new(); - - while !content.is_empty() { - if content.peek(Paren) { - // Parse tuple: ("url", "description") - let tuple_content; - syn::parenthesized!(tuple_content in content); - let url: LitStr = tuple_content.parse()?; - let url_value = validate_server_url(&url)?; - let description = if tuple_content.peek(syn::Token![,]) { - tuple_content.parse::()?; - Some(tuple_content.parse::()?.value()) - } else { - None - }; - servers.push(ServerConfig { - url: url_value, - description, - }); - } else if content.peek(Brace) { - // Parse struct-like: {url = "...", description = "..."} - let server = parse_server_struct(&content)?; - servers.push(server); - } else { - // Parse simple string: "url" - let url: LitStr = content.parse()?; - let url_value = validate_server_url(&url)?; - servers.push(ServerConfig { - url: url_value, - description: None, - }); - } - - if content.peek(syn::Token![,]) { - content.parse::()?; - } else { - break; - } - } - - Ok(servers) - } else if input.peek(syn::token::Brace) { - // Single struct-like format: servers = {url = "...", description = "..."} - let server = parse_server_struct(input)?; - Ok(vec![server]) - } else { - // Single string: servers = "url" - let single: LitStr = input.parse()?; - let url_value = validate_server_url(&single)?; - Ok(vec![ServerConfig { - url: url_value, - description: None, - }]) - } -} - -/// Parse a single server in struct-like format: {url = "...", description = "..."} -fn parse_server_struct(input: ParseStream) -> syn::Result { - let content; - syn::braced!(content in input); - - let mut url: Option = None; - let mut description: Option = None; - - while !content.is_empty() { - let ident: syn::Ident = content.parse()?; - let ident_str = ident.to_string(); - - match ident_str.as_str() { - "url" => { - content.parse::()?; - let url_lit: LitStr = content.parse()?; - url = Some(validate_server_url(&url_lit)?); - } - "description" => { - content.parse::()?; - description = Some(content.parse::()?.value()); - } - _ => { - return Err(syn::Error::new( - ident.span(), - format!("unknown field: `{ident_str}`. Expected `url` or `description`"), - )); - } - } - - if content.peek(syn::Token![,]) { - content.parse::()?; - } else { - break; - } - } - - let url = url.ok_or_else(|| syn::Error::new(proc_macro2::Span::call_site(), "vespera! macro: server configuration missing required `url` field. Use format: `servers = { url = \"http://localhost:3000\" }` or `servers = { url = \"...\", description = \"...\" }`."))?; - - Ok(ServerConfig { url, description }) -} - -/// Processed vespera input with extracted values -pub struct ProcessedVesperaInput { - pub folder_name: String, - pub openapi_file_names: Vec, - pub title: Option, - pub version: Option, - pub docs_url: Option, - pub redoc_url: Option, - pub servers: Option>, - /// Apps to merge (`syn::Path` for code generation) - pub merge: Vec, -} - -/// Process `AutoRouterInput` into extracted values -pub fn process_vespera_input(input: AutoRouterInput) -> ProcessedVesperaInput { - ProcessedVesperaInput { - folder_name: input - .dir - .map_or_else(|| "routes".to_string(), |f| f.value()), - openapi_file_names: input - .openapi - .unwrap_or_default() - .into_iter() - .map(|f| f.value()) - .collect(), - title: input.title.map(|t| t.value()), - version: input.version.map(|v| v.value()), - docs_url: input.docs_url.map(|u| u.value()), - redoc_url: input.redoc_url.map(|u| u.value()), - servers: input.servers.map(|svrs| { - svrs.into_iter() - .map(|s| Server { - url: s.url, - description: s.description, - variables: None, - }) - .collect() - }), - merge: input.merge.unwrap_or_default(), - } -} - -/// Input for `export_app`! macro -pub struct ExportAppInput { - /// App name (struct name to generate) - pub name: syn::Ident, - /// Route directory - pub dir: Option, -} - -impl Parse for ExportAppInput { - fn parse(input: ParseStream) -> syn::Result { - let name: syn::Ident = input.parse()?; - - let mut dir = None; - - // Parse optional comma and arguments - while input.peek(syn::Token![,]) { - input.parse::()?; - - if input.is_empty() { - break; - } - - let ident: syn::Ident = input.parse()?; - let ident_str = ident.to_string(); - - match ident_str.as_str() { - "dir" => { - input.parse::()?; - dir = Some(input.parse()?); - } - _ => { - return Err(syn::Error::new( - ident.span(), - format!("unknown field: `{ident_str}`. Expected `dir`"), - )); - } - } - } - - Ok(Self { name, dir }) - } -} - -/// Swagger UI HTML template. Contains `{}` format placeholder for the OpenAPI spec JSON. -const SWAGGER_UI_HTML: &str = r##"Swagger UI

"##; - -/// ReDoc HTML template. Contains `{}` format placeholder for the OpenAPI spec JSON. -const REDOC_HTML: &str = r#"ReDoc
"#; - -/// Generate a documentation route handler (Swagger UI or ReDoc). -/// -/// When `has_merge` is true, the handler merges specs from child apps at runtime. -/// When false, it serves the spec directly from the compile-time constant. -fn generate_docs_route_tokens( - url: &str, - html_template: &str, - merge_spec_code: &[proc_macro2::TokenStream], - has_merge: bool, -) -> proc_macro2::TokenStream { - let method_path = http_method_to_token_stream(HttpMethod::Get); - - if has_merge { - quote!( - .route(#url, #method_path(|| async { - static MERGED_SPEC: std::sync::OnceLock = std::sync::OnceLock::new(); - let spec = MERGED_SPEC.get_or_init(|| { - let mut merged: vespera::OpenApi = vespera::serde_json::from_str(__VESPERA_SPEC).unwrap(); - #(#merge_spec_code)* - vespera::serde_json::to_string(&merged).unwrap() - }); - static HTML: std::sync::OnceLock = std::sync::OnceLock::new(); - let html = HTML.get_or_init(|| { - format!(#html_template, spec) - }); - vespera::axum::response::Html(html.as_str()) - })) - ) - } else { - quote!( - .route(#url, #method_path(|| async { - static HTML: std::sync::OnceLock = std::sync::OnceLock::new(); - let html = HTML.get_or_init(|| { - format!(#html_template, __VESPERA_SPEC) - }); - vespera::axum::response::Html(html.as_str()) - })) - ) - } -} -/// Generate cron scheduler spawn code from collected cron metadata. -fn generate_cron_scheduler_code(cron_jobs: &[CronMetadata]) -> proc_macro2::TokenStream { - if cron_jobs.is_empty() { - return quote!(); - } - - let job_additions: Vec = cron_jobs - .iter() - .map(|cron| { - let expression = &cron.expression; - let module_path = &cron.module_path; - let function_name = &cron.function_name; - - // Build the full path: crate::module::function - let mut p: syn::punctuated::Punctuated = - syn::punctuated::Punctuated::new(); - p.push(syn::PathSegment { - ident: syn::Ident::new("crate", Span::call_site()), - arguments: syn::PathArguments::None, - }); - p.extend(module_path.split("::").filter_map(|s| { - if s.is_empty() { - None - } else { - Some(syn::PathSegment { - ident: syn::Ident::new(s, Span::call_site()), - arguments: syn::PathArguments::None, - }) - } - })); - let func_ident = syn::Ident::new(function_name, Span::call_site()); - - let err_create = format!("vespera: failed to create cron job '{function_name}'"); - let err_add = format!("vespera: failed to add cron job '{function_name}'"); - - quote! { - __vespera_cron_scheduler.add( - vespera::tokio_cron_scheduler::Job::new_async(#expression, |_uuid, _l| { - Box::pin(async move { - #p::#func_ident().await; - }) - }).expect(#err_create) - ).await.expect(#err_add); - } - }) - .collect(); - - quote! { - vespera::tokio::spawn(async move { - let mut __vespera_cron_scheduler = vespera::tokio_cron_scheduler::JobScheduler::new().await - .expect("vespera: failed to create cron scheduler"); - #(#job_additions)* - __vespera_cron_scheduler.start().await - .expect("vespera: failed to start cron scheduler"); - // Keep scheduler alive forever - ::std::future::pending::<()>().await; - }); - } -} - -/// Generate Axum router code from collected metadata -#[allow(clippy::too_many_lines)] -pub fn generate_router_code( - metadata: &CollectedMetadata, - docs_url: Option<&str>, - redoc_url: Option<&str>, - spec_tokens: Option, - merge_apps: &[syn::Path], - cron_jobs: &[CronMetadata], -) -> proc_macro2::TokenStream { - let mut router_nests = Vec::new(); - - for route in &metadata.routes { - let Ok(http_method) = HttpMethod::try_from(route.method.as_str()) else { - eprintln!( - "vespera: skipping route '{}' — unknown HTTP method '{}'", - route.path, route.method - ); - continue; - }; - let method_path = http_method_to_token_stream(http_method); - let path = &route.path; - let module_path = &route.module_path; - let function_name = &route.function_name; - - let mut p: syn::punctuated::Punctuated = - syn::punctuated::Punctuated::new(); - p.push(syn::PathSegment { - ident: syn::Ident::new("crate", Span::call_site()), - arguments: syn::PathArguments::None, - }); - p.extend(module_path.split("::").filter_map(|s| { - if s.is_empty() { - None - } else { - Some(syn::PathSegment { - ident: syn::Ident::new(s, Span::call_site()), - arguments: syn::PathArguments::None, - }) - } - })); - let func_name = syn::Ident::new(function_name, Span::call_site()); - router_nests.push(quote!( - .route(#path, #method_path(#p::#func_name)) - )); - } - - // Check if we need to merge specs at runtime - let has_merge = !merge_apps.is_empty(); - - // Generate merge code once, reuse in both docs_url and redoc_url routes - let merge_spec_code: Vec<_> = merge_apps - .iter() - .map(|app_path| { - quote! { - if let Ok(other) = vespera::serde_json::from_str::(#app_path::OPENAPI_SPEC) { - merged.merge(other); - } - } - }) - .collect(); - - if let Some(docs_url) = docs_url { - router_nests.push(generate_docs_route_tokens( - docs_url, - SWAGGER_UI_HTML, - &merge_spec_code, - has_merge, - )); - } - - if let Some(redoc_url) = redoc_url { - router_nests.push(generate_docs_route_tokens( - redoc_url, - REDOC_HTML, - &merge_spec_code, - has_merge, - )); - } - - let needs_spec_const = spec_tokens.is_some() && (docs_url.is_some() || redoc_url.is_some()); - let cron_code = generate_cron_scheduler_code(cron_jobs); - - if needs_spec_const { - let spec_expr = spec_tokens.unwrap(); - if merge_apps.is_empty() { - quote! { - { - const __VESPERA_SPEC: &str = #spec_expr; - #cron_code - vespera::axum::Router::new() - #( #router_nests )* - } - } - } else { - quote! { - { - const __VESPERA_SPEC: &str = #spec_expr; - #cron_code - vespera::VesperaRouter::new( - vespera::axum::Router::new() - #( #router_nests )*, - vec![#( #merge_apps::router ),*] - ) - } - } - } - } else if merge_apps.is_empty() { - if cron_jobs.is_empty() { - quote! { - vespera::axum::Router::new() - #( #router_nests )* - } - } else { - quote! { - { - #cron_code - vespera::axum::Router::new() - #( #router_nests )* - } - } - } - } else { - // When merging apps, return VesperaRouter which defers the merge - // until with_state() is called. This is necessary because Axum requires - // merged routers to have the same state type. - if cron_jobs.is_empty() { - quote! { - vespera::VesperaRouter::new( - vespera::axum::Router::new() - #( #router_nests )*, - vec![#( #merge_apps::router ),*] - ) - } - } else { - quote! { - { - #cron_code - vespera::VesperaRouter::new( - vespera::axum::Router::new() - #( #router_nests )*, - vec![#( #merge_apps::router ),*] - ) - } - } - } - } -} - -#[cfg(test)] -mod tests { - use std::fs; - - use rstest::rstest; - use tempfile::TempDir; - - use super::*; - use crate::collector::collect_metadata; - - fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> std::path::PathBuf { - let file_path = dir.path().join(filename); - if let Some(parent) = file_path.parent() { - fs::create_dir_all(parent).expect("Failed to create parent directory"); - } - fs::write(&file_path, content).expect("Failed to write temp file"); - file_path - } - - #[test] - fn test_generate_router_code_empty() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name, &[]) - .unwrap() - .0, - None, - None, - None, - &[], - &[], - ); - let code = result.to_string(); - - // Should generate empty router - // quote! generates "vespera :: axum :: Router :: new ()" format - assert!( - code.contains("Router") && code.contains("new"), - "Code should contain Router::new(), got: {code}" - ); - assert!( - !code.contains("route"), - "Code should not contain route, got: {code}" - ); - - drop(temp_dir); - } - - #[rstest] - #[case::single_get_route( - "routes", - vec![( - "users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - )], - "get", - "/users", - "routes::users::get_users", - )] - #[case::single_post_route( - "routes", - vec![( - "create_user.rs", - r#" -#[route(post)] -pub fn create_user() -> String { - "created".to_string() -} -"#, - )], - "post", - "/create-user", - "routes::create_user::create_user", - )] - #[case::single_put_route( - "routes", - vec![( - "update_user.rs", - r#" -#[route(put)] -pub fn update_user() -> String { - "updated".to_string() -} -"#, - )], - "put", - "/update-user", - "routes::update_user::update_user", - )] - #[case::single_delete_route( - "routes", - vec![( - "delete_user.rs", - r#" -#[route(delete)] -pub fn delete_user() -> String { - "deleted".to_string() -} -"#, - )], - "delete", - "/delete-user", - "routes::delete_user::delete_user", - )] - #[case::single_patch_route( - "routes", - vec![( - "patch_user.rs", - r#" -#[route(patch)] -pub fn patch_user() -> String { - "patched".to_string() -} -"#, - )], - "patch", - "/patch-user", - "routes::patch_user::patch_user", - )] - #[case::route_with_custom_path( - "routes", - vec![( - "users.rs", - r#" -#[route(get, path = "/api/users")] -pub fn get_users() -> String { - "users".to_string() -} -"#, - )], - "get", - "/users/api/users", - "routes::users::get_users", - )] - #[case::nested_module( - "routes", - vec![( - "api/users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - )], - "get", - "/api/users", - "routes::api::users::get_users", - )] - #[case::deeply_nested_module( - "routes", - vec![( - "api/v1/users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - )], - "get", - "/api/v1/users", - "routes::api::v1::users::get_users", - )] - fn test_generate_router_code_single_route( - #[case] folder_name: &str, - #[case] files: Vec<(&str, &str)>, - #[case] expected_method: &str, - #[case] expected_path: &str, - #[case] expected_function_path: &str, - ) { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - for (filename, content) in files { - create_temp_file(&temp_dir, filename, content); - } - - let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name, &[]) - .unwrap() - .0, - None, - None, - None, - &[], - &[], - ); - let code = result.to_string(); - - // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") - assert!( - code.contains("Router") && code.contains("new"), - "Code should contain Router::new(), got: {code}" - ); - - // Check route method - assert!( - code.contains(expected_method), - "Code should contain method: {expected_method}, got: {code}" - ); - - // Check route path - assert!( - code.contains(expected_path), - "Code should contain path: {expected_path}, got: {code}" - ); - - // Check function path (quote! adds spaces, so we check for parts) - let function_parts: Vec<&str> = expected_function_path.split("::").collect(); - for part in &function_parts { - if !part.is_empty() { - assert!( - code.contains(part), - "Code should contain function part: {part}, got: {code}" - ); - } - } - - drop(temp_dir); - } - - #[test] - fn test_generate_router_code_multiple_routes() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - // Create multiple route files - create_temp_file( - &temp_dir, - "users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - ); - - create_temp_file( - &temp_dir, - "create_user.rs", - r#" -#[route(post)] -pub fn create_user() -> String { - "created".to_string() -} -"#, - ); - - create_temp_file( - &temp_dir, - "update_user.rs", - r#" -#[route(put)] -pub fn update_user() -> String { - "updated".to_string() -} -"#, - ); - - let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name, &[]) - .unwrap() - .0, - None, - None, - None, - &[], - &[], - ); - let code = result.to_string(); - - // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") - assert!(code.contains("Router") && code.contains("new")); - - // Check all routes are present - assert!(code.contains("get_users")); - assert!(code.contains("create_user")); - assert!(code.contains("update_user")); - - // Check methods - assert!(code.contains("get")); - assert!(code.contains("post")); - assert!(code.contains("put")); - - // Count route calls (quote! generates ". route (" with spaces) - // Count occurrences of ". route (" pattern - let route_count = code.matches(". route (").count(); - assert_eq!( - route_count, 3, - "Should have 3 route calls, got: {route_count}, code: {code}" - ); - - drop(temp_dir); - } - - #[test] - fn test_generate_router_code_same_path_different_methods() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - // Create routes with same path but different methods - create_temp_file( - &temp_dir, - "users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} - -#[route(post)] -pub fn create_users() -> String { - "created".to_string() -} -"#, - ); - - let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name, &[]) - .unwrap() - .0, - None, - None, - None, - &[], - &[], - ); - let code = result.to_string(); - - // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") - assert!(code.contains("Router") && code.contains("new")); - - // Check both routes are present - assert!(code.contains("get_users")); - assert!(code.contains("create_users")); - - // Check methods - assert!(code.contains("get")); - assert!(code.contains("post")); - - // Should have 2 routes (quote! generates ". route (" with spaces) - let route_count = code.matches(". route (").count(); - assert_eq!( - route_count, 2, - "Should have 2 routes, got: {route_count}, code: {code}" - ); - - drop(temp_dir); - } - - #[test] - fn test_generate_router_code_with_mod_rs() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - // Create mod.rs file - create_temp_file( - &temp_dir, - "mod.rs", - r#" -#[route(get)] -pub fn index() -> String { - "index".to_string() -} -"#, - ); - - let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name, &[]) - .unwrap() - .0, - None, - None, - None, - &[], - &[], - ); - let code = result.to_string(); - - // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") - assert!(code.contains("Router") && code.contains("new")); - - // Check route is present - assert!(code.contains("index")); - - // Path should be / (mod.rs maps to root, segments is empty) - // quote! generates "\"/\"" - assert!(code.contains("\"/\"")); - - drop(temp_dir); - } - - #[test] - fn test_generate_router_code_empty_folder_name() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = ""; - - create_temp_file( - &temp_dir, - "users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - ); - - let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name, &[]) - .unwrap() - .0, - None, - None, - None, - &[], - &[], - ); - let code = result.to_string(); - - // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") - assert!(code.contains("Router") && code.contains("new")); - - // Check route is present - assert!(code.contains("get_users")); - - // Module path should not have double colons - assert!(!code.contains("::users::users")); - - drop(temp_dir); - } - - // ========== Tests for parsing functions ========== - - #[test] - fn test_parse_openapi_values_single() { - // Test that single string openapi value parses correctly via AutoRouterInput - let tokens = quote::quote!(openapi = "openapi.json"); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let openapi = input.openapi.unwrap(); - assert_eq!(openapi.len(), 1); - assert_eq!(openapi[0].value(), "openapi.json"); - } - - #[test] - fn test_parse_openapi_values_array() { - // Test that array openapi value parses correctly via AutoRouterInput - let tokens = quote::quote!(openapi = ["openapi.json", "api.json"]); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let openapi = input.openapi.unwrap(); - assert_eq!(openapi.len(), 2); - assert_eq!(openapi[0].value(), "openapi.json"); - assert_eq!(openapi[1].value(), "api.json"); - } - - #[test] - fn test_validate_server_url_valid_http() { - let lit = LitStr::new("http://localhost:3000", Span::call_site()); - let result = validate_server_url(&lit); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), "http://localhost:3000"); - } - - #[test] - fn test_validate_server_url_valid_https() { - let lit = LitStr::new("https://api.example.com", Span::call_site()); - let result = validate_server_url(&lit); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), "https://api.example.com"); - } - - #[test] - fn test_validate_server_url_invalid() { - let lit = LitStr::new("ftp://example.com", Span::call_site()); - let result = validate_server_url(&lit); - assert!(result.is_err()); - } - - #[test] - fn test_validate_server_url_no_scheme() { - let lit = LitStr::new("example.com", Span::call_site()); - let result = validate_server_url(&lit); - assert!(result.is_err()); - } - - #[test] - fn test_auto_router_input_parse_dir_only() { - let tokens = quote::quote!(dir = "api"); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - assert_eq!(input.dir.unwrap().value(), "api"); - assert!(input.openapi.is_none()); - } - - #[test] - fn test_auto_router_input_parse_string_as_dir() { - let tokens = quote::quote!("routes"); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - assert_eq!(input.dir.unwrap().value(), "routes"); - } - - #[test] - fn test_auto_router_input_parse_openapi_single() { - let tokens = quote::quote!(openapi = "openapi.json"); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let openapi = input.openapi.unwrap(); - assert_eq!(openapi.len(), 1); - assert_eq!(openapi[0].value(), "openapi.json"); - } - - #[test] - fn test_auto_router_input_parse_openapi_array() { - let tokens = quote::quote!(openapi = ["a.json", "b.json"]); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let openapi = input.openapi.unwrap(); - assert_eq!(openapi.len(), 2); - } - - #[test] - fn test_auto_router_input_parse_title_version() { - let tokens = quote::quote!(title = "My API", version = "2.0.0"); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - assert_eq!(input.title.unwrap().value(), "My API"); - assert_eq!(input.version.unwrap().value(), "2.0.0"); - } - - #[test] - fn test_auto_router_input_parse_docs_redoc() { - let tokens = quote::quote!(docs_url = "/docs", redoc_url = "/redoc"); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - assert_eq!(input.docs_url.unwrap().value(), "/docs"); - assert_eq!(input.redoc_url.unwrap().value(), "/redoc"); - } - - #[test] - fn test_auto_router_input_parse_servers_single() { - let tokens = quote::quote!(servers = "http://localhost:3000"); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let servers = input.servers.unwrap(); - assert_eq!(servers.len(), 1); - assert_eq!(servers[0].url, "http://localhost:3000"); - assert!(servers[0].description.is_none()); - } - - #[test] - fn test_auto_router_input_parse_servers_array_strings() { - let tokens = quote::quote!(servers = ["http://localhost:3000", "https://api.example.com"]); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let servers = input.servers.unwrap(); - assert_eq!(servers.len(), 2); - } - - #[test] - fn test_auto_router_input_parse_servers_tuple() { - let tokens = quote::quote!(servers = [("http://localhost:3000", "Development")]); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let servers = input.servers.unwrap(); - assert_eq!(servers.len(), 1); - assert_eq!(servers[0].url, "http://localhost:3000"); - assert_eq!(servers[0].description, Some("Development".to_string())); - } - - #[test] - fn test_auto_router_input_parse_servers_struct() { - let tokens = - quote::quote!(servers = [{ url = "http://localhost:3000", description = "Dev" }]); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let servers = input.servers.unwrap(); - assert_eq!(servers.len(), 1); - assert_eq!(servers[0].url, "http://localhost:3000"); - assert_eq!(servers[0].description, Some("Dev".to_string())); - } - - #[test] - fn test_auto_router_input_parse_servers_single_struct() { - let tokens = quote::quote!(servers = { url = "https://api.example.com" }); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let servers = input.servers.unwrap(); - assert_eq!(servers.len(), 1); - assert_eq!(servers[0].url, "https://api.example.com"); - } - - #[test] - fn test_auto_router_input_parse_unknown_field() { - let tokens = quote::quote!(unknown_field = "value"); - let result: syn::Result = syn::parse2(tokens); - assert!(result.is_err()); - } - - #[test] - fn test_auto_router_input_parse_all_fields() { - let tokens = quote::quote!( - dir = "api", - openapi = "openapi.json", - title = "Test API", - version = "1.0.0", - docs_url = "/docs", - redoc_url = "/redoc", - servers = "http://localhost:3000" - ); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - assert!(input.dir.is_some()); - assert!(input.openapi.is_some()); - assert!(input.title.is_some()); - assert!(input.version.is_some()); - assert!(input.docs_url.is_some()); - assert!(input.redoc_url.is_some()); - assert!(input.servers.is_some()); - } - - #[test] - fn test_generate_router_code_with_docs() { - let metadata = CollectedMetadata::new(); - let spec = r#"{"openapi":"3.1.0"}"#; - - let result = generate_router_code( - &metadata, - Some("/docs"), - None, - Some(quote::quote!(#spec)), - &[], - &[], - ); - let code = result.to_string(); - - assert!(code.contains("/docs")); - assert!(code.contains("swagger-ui")); - assert!(code.contains("__VESPERA_SPEC")); - assert!(code.contains("OnceLock")); - } - - #[test] - fn test_generate_router_code_with_redoc() { - let metadata = CollectedMetadata::new(); - let spec = r#"{"openapi":"3.1.0"}"#; - - let result = generate_router_code( - &metadata, - None, - Some("/redoc"), - Some(quote::quote!(#spec)), - &[], - &[], - ); - let code = result.to_string(); - - assert!(code.contains("/redoc")); - assert!(code.contains("redoc")); - assert!(code.contains("__VESPERA_SPEC")); - assert!(code.contains("OnceLock")); - } - - #[test] - fn test_generate_router_code_with_both_docs() { - let metadata = CollectedMetadata::new(); - let spec = r#"{"openapi":"3.1.0"}"#; - - let result = generate_router_code( - &metadata, - Some("/docs"), - Some("/redoc"), - Some(quote::quote!(#spec)), - &[], - &[], - ); - let code = result.to_string(); - - assert!(code.contains("/docs")); - assert!(code.contains("/redoc")); - assert!(code.contains("__VESPERA_SPEC")); - } - - #[test] - fn test_swagger_html_template_renders_valid_quotes() { - assert!( - !SWAGGER_UI_HTML.contains(r#"\""#), - "Swagger template should not contain literal backslash-quotes: {SWAGGER_UI_HTML}" - ); - assert!( - SWAGGER_UI_HTML.contains(r#"href="https://unpkg.com/swagger-ui-dist/swagger-ui.css""#) - ); - assert!( - SWAGGER_UI_HTML - .contains(r#"src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js""#) - ); - assert!(SWAGGER_UI_HTML.contains(r##"dom_id: "#swagger-ui""##)); - } - - #[test] - fn test_redoc_html_template_renders_valid_quotes() { - assert!( - !REDOC_HTML.contains(r#"\""#), - "ReDoc template should not contain literal backslash-quotes: {REDOC_HTML}" - ); - assert!( - REDOC_HTML.contains(r#"href="https://unpkg.com/redoc/bundles/redoc.standalone.css""#) - ); - assert!( - REDOC_HTML.contains(r#"src="https://unpkg.com/redoc/bundles/redoc.standalone.js""#) - ); - assert!(REDOC_HTML.contains(r#"document.getElementById("redoc-container")"#)); - } - - #[test] - fn test_parse_server_struct_url_only() { - // Test server struct parsing via AutoRouterInput - let tokens = quote::quote!(servers = { url = "http://localhost:3000" }); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let servers = input.servers.unwrap(); - assert_eq!(servers.len(), 1); - assert_eq!(servers[0].url, "http://localhost:3000"); - assert!(servers[0].description.is_none()); - } - - #[test] - fn test_parse_server_struct_with_description() { - let tokens = - quote::quote!(servers = { url = "http://localhost:3000", description = "Local" }); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let servers = input.servers.unwrap(); - assert_eq!(servers[0].description, Some("Local".to_string())); - } - - #[test] - fn test_parse_server_struct_unknown_field() { - let tokens = quote::quote!(servers = { url = "http://localhost:3000", unknown = "test" }); - let result: syn::Result = syn::parse2(tokens); - assert!(result.is_err()); - } - - #[test] - fn test_parse_server_struct_missing_url() { - let tokens = quote::quote!(servers = { description = "test" }); - let result: syn::Result = syn::parse2(tokens); - assert!(result.is_err()); - } - - #[test] - fn test_parse_servers_tuple_url_only() { - let tokens = quote::quote!(servers = [("http://localhost:3000")]); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let servers = input.servers.unwrap(); - assert_eq!(servers.len(), 1); - assert!(servers[0].description.is_none()); - } - - #[test] - fn test_parse_servers_invalid_url() { - let tokens = quote::quote!(servers = "invalid-url"); - let result: syn::Result = syn::parse2(tokens); - assert!(result.is_err()); - } - - #[test] - fn test_generate_router_code_unknown_http_method() { - // Test lines 337-340: route with unknown HTTP method is skipped in router codegen - let mut metadata = CollectedMetadata { - routes: Vec::new(), - structs: Vec::new(), - crons: Vec::new(), - }; - metadata.routes.push(crate::metadata::RouteMetadata { - method: "INVALID".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "routes::users".to_string(), - file_path: "dummy.rs".to_string(), - signature: "fn get_users() -> String".to_string(), - error_status: None, - tags: None, - description: None, - }); - - let result = generate_router_code(&metadata, None, None, None, &[], &[]); - let code = result.to_string(); - - // Router should be generated but without any route calls - assert!( - code.contains("Router") && code.contains("new"), - "Code should contain Router::new(), got: {code}" - ); - assert!( - !code.contains(". route ("), - "Route with unknown HTTP method should be skipped, got: {code}" - ); - } - - #[test] - fn test_generate_router_code_unknown_method_skipped_valid_kept() { - // Test that unknown methods are skipped while valid routes are still generated - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - create_temp_file( - &temp_dir, - "users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - ); - - let (mut metadata, _file_asts) = - collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); - // Inject an additional route with invalid method - metadata.routes.push(crate::metadata::RouteMetadata { - method: "CONNECT".to_string(), - path: "/invalid".to_string(), - function_name: "connect_handler".to_string(), - module_path: "routes::invalid".to_string(), - file_path: "dummy.rs".to_string(), - signature: "fn connect_handler() -> String".to_string(), - error_status: None, - tags: None, - description: None, - }); - - let result = generate_router_code(&metadata, None, None, None, &[], &[]); - let code = result.to_string(); - - // Valid route should be present - assert!( - code.contains("get_users"), - "Valid route should be present, got: {code}" - ); - // Invalid route should be skipped - assert!( - !code.contains("connect_handler"), - "Invalid method route should be skipped, got: {code}" - ); - - drop(temp_dir); - } - - #[test] - fn test_auto_router_input_parse_invalid_token() { - // Test line 149: neither ident nor string literal triggers lookahead error - let tokens = quote::quote!(123); - let result: syn::Result = syn::parse2(tokens); - assert!(result.is_err()); - } - - #[test] - fn test_auto_router_input_empty() { - // Test empty input - should use defaults/env vars - let tokens = quote::quote!(); - let result: syn::Result = syn::parse2(tokens); - assert!(result.is_ok()); - } - - #[test] - fn test_auto_router_input_multiple_commas() { - // Test input with trailing comma - let tokens = quote::quote!(dir = "api",); - let result: syn::Result = syn::parse2(tokens); - assert!(result.is_ok()); - } - - #[test] - fn test_auto_router_input_no_comma() { - // Test input without comma between fields (should stop at second field) - let tokens = quote::quote!(dir = "api" title = "Test"); - let result: syn::Result = syn::parse2(tokens); - // This should fail or only parse first field - assert!(result.is_err()); - } - - // ========== Tests for process_vespera_input ========== - - #[test] - fn test_process_vespera_input_defaults() { - let tokens = quote::quote!(); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let processed = process_vespera_input(input); - assert_eq!(processed.folder_name, "routes"); - assert!(processed.openapi_file_names.is_empty()); - assert!(processed.title.is_none()); - assert!(processed.docs_url.is_none()); - } - - #[test] - fn test_process_vespera_input_all_fields() { - let tokens = quote::quote!( - dir = "api", - openapi = ["openapi.json", "api.json"], - title = "My API", - version = "1.0.0", - docs_url = "/docs", - redoc_url = "/redoc", - servers = "http://localhost:3000" - ); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let processed = process_vespera_input(input); - assert_eq!(processed.folder_name, "api"); - assert_eq!( - processed.openapi_file_names, - vec!["openapi.json", "api.json"] - ); - assert_eq!(processed.title, Some("My API".to_string())); - assert_eq!(processed.version, Some("1.0.0".to_string())); - assert_eq!(processed.docs_url, Some("/docs".to_string())); - assert_eq!(processed.redoc_url, Some("/redoc".to_string())); - let servers = processed.servers.unwrap(); - assert_eq!(servers.len(), 1); - assert_eq!(servers[0].url, "http://localhost:3000"); - } - - #[test] - fn test_process_vespera_input_servers_with_description() { - let tokens = quote::quote!( - servers = [{ url = "https://api.example.com", description = "Production" }] - ); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let processed = process_vespera_input(input); - let servers = processed.servers.unwrap(); - assert_eq!(servers[0].url, "https://api.example.com"); - assert_eq!(servers[0].description, Some("Production".to_string())); - } - - // ========== Tests for parse_merge_values ========== - - #[test] - fn test_parse_merge_values_single() { - let tokens = quote::quote!(merge = [some::path::App]); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let merge = input.merge.unwrap(); - assert_eq!(merge.len(), 1); - // Check the path segments - let path = &merge[0]; - let segments: Vec<_> = path.segments.iter().map(|s| s.ident.to_string()).collect(); - assert_eq!(segments, vec!["some", "path", "App"]); - } - - #[test] - fn test_parse_merge_values_multiple() { - let tokens = quote::quote!(merge = [first::App, second::Other]); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let merge = input.merge.unwrap(); - assert_eq!(merge.len(), 2); - } - - #[test] - fn test_parse_merge_values_empty() { - let tokens = quote::quote!(merge = []); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let merge = input.merge.unwrap(); - assert!(merge.is_empty()); - } - - #[test] - fn test_parse_merge_values_with_trailing_comma() { - let tokens = quote::quote!(merge = [app::MyApp,]); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let merge = input.merge.unwrap(); - assert_eq!(merge.len(), 1); - } - - // ========== Tests for generate_router_code with merge ========== - - #[test] - fn test_generate_router_code_with_merge_apps() { - let metadata = CollectedMetadata::new(); - let merge_apps: Vec = vec![syn::parse_quote!(third::ThirdApp)]; - - let result = generate_router_code(&metadata, None, None, None, &merge_apps, &[]); - let code = result.to_string(); - - // Should use VesperaRouter instead of plain Router - assert!( - code.contains("VesperaRouter"), - "Should use VesperaRouter for merge, got: {code}" - ); - assert!( - code.contains("third :: ThirdApp") || code.contains("third::ThirdApp"), - "Should reference merged app, got: {code}" - ); - } - - #[test] - fn test_generate_router_code_with_docs_and_merge() { - let metadata = CollectedMetadata::new(); - let spec = r#"{"openapi":"3.1.0"}"#; - let merge_apps: Vec = vec![syn::parse_quote!(app::MyApp)]; - - let result = generate_router_code( - &metadata, - Some("/docs"), - None, - Some(quote::quote!(#spec)), - &merge_apps, - &[], - ); - let code = result.to_string(); - - // Should have merge code for docs - assert!( - code.contains("OnceLock"), - "Should use OnceLock for merged docs, got: {code}" - ); - assert!( - code.contains("MERGED_SPEC"), - "Should have MERGED_SPEC, got: {code}" - ); - // quote! generates "merged . merge" with spaces - assert!( - code.contains("merged . merge") || code.contains("merged.merge"), - "Should call merge on spec, got: {code}" - ); - } - - #[test] - fn test_generate_router_code_with_redoc_and_merge() { - let metadata = CollectedMetadata::new(); - let spec = r#"{"openapi":"3.1.0"}"#; - let merge_apps: Vec = vec![syn::parse_quote!(other::OtherApp)]; - - let result = generate_router_code( - &metadata, - None, - Some("/redoc"), - Some(quote::quote!(#spec)), - &merge_apps, - &[], - ); - let code = result.to_string(); - - // Should have merge code for redoc - assert!( - code.contains("OnceLock"), - "Should use OnceLock for merged redoc" - ); - assert!(code.contains("redoc"), "Should contain redoc"); - } - - #[test] - fn test_generate_router_code_with_both_docs_and_merge() { - let metadata = CollectedMetadata::new(); - let spec = r#"{"openapi":"3.1.0"}"#; - let merge_apps: Vec = vec![syn::parse_quote!(merged::App)]; - - let result = generate_router_code( - &metadata, - Some("/docs"), - Some("/redoc"), - Some(quote::quote!(#spec)), - &merge_apps, - &[], - ); - let code = result.to_string(); - - // Both docs should have merge code - // Count MERGED_SPEC occurrences - should appear in docs and redoc handlers - let merged_spec_count = code.matches("MERGED_SPEC").count(); - assert!( - merged_spec_count >= 2, - "Should have at least 2 MERGED_SPEC for docs and redoc, got: {merged_spec_count}" - ); - // __VESPERA_SPEC should appear exactly once (the const declaration) - let vespera_spec_count = code.matches("__VESPERA_SPEC").count(); - assert!( - vespera_spec_count >= 1, - "Should have __VESPERA_SPEC const, got: {vespera_spec_count}" - ); - // Both docs_url and redoc_url should be present - assert!( - code.contains("/docs") && code.contains("/redoc"), - "Should contain both /docs and /redoc" - ); - } - - #[test] - fn test_generate_router_code_with_multiple_merge_apps() { - let metadata = CollectedMetadata::new(); - let merge_apps: Vec = vec![ - syn::parse_quote!(first::App), - syn::parse_quote!(second::App), - ]; - - let result = generate_router_code(&metadata, None, None, None, &merge_apps, &[]); - let code = result.to_string(); - - // Should reference both apps - assert!( - code.contains("first") && code.contains("second"), - "Should reference both merge apps, got: {code}" - ); - } - - // ========== Tests for generate_router_code with cron jobs ========== - - #[test] - fn test_generate_router_code_with_merge_and_cron() { - let metadata = CollectedMetadata::new(); - let merge_apps: Vec = vec![syn::parse_quote!(third::ThirdApp)]; - let cron_jobs = vec![CronMetadata { - expression: "0 */5 * * * *".to_string(), - function_name: "cleanup".to_string(), - module_path: "tasks".to_string(), - file_path: "src/tasks.rs".to_string(), - }]; - - let result = generate_router_code(&metadata, None, None, None, &merge_apps, &cron_jobs); - let code = result.to_string(); - - assert!( - code.contains("VesperaRouter"), - "Should use VesperaRouter for merge, got: {code}" - ); - assert!( - code.contains("JobScheduler"), - "Should contain cron scheduler code, got: {code}" - ); - assert!( - code.contains("cleanup"), - "Should reference cron function, got: {code}" - ); - } - - #[test] - fn test_generate_router_code_with_cron_no_merge() { - let metadata = CollectedMetadata::new(); - let cron_jobs = vec![CronMetadata { - expression: "1/10 * * * * *".to_string(), - function_name: "heartbeat".to_string(), - module_path: "cron::health".to_string(), - file_path: "src/cron/health.rs".to_string(), - }]; - - let result = generate_router_code(&metadata, None, None, None, &[], &cron_jobs); - let code = result.to_string(); - - assert!( - !code.contains("VesperaRouter"), - "Should NOT use VesperaRouter without merge, got: {code}" - ); - assert!( - code.contains("JobScheduler"), - "Should contain cron scheduler code, got: {code}" - ); - assert!( - code.contains("heartbeat"), - "Should reference cron function, got: {code}" - ); - } - - // ========== Tests for ExportAppInput parsing ========== - - #[test] - fn test_export_app_input_name_only() { - let tokens = quote::quote!(MyApp); - let input: ExportAppInput = syn::parse2(tokens).unwrap(); - assert_eq!(input.name.to_string(), "MyApp"); - assert!(input.dir.is_none()); - } - - #[test] - fn test_export_app_input_with_dir() { - let tokens = quote::quote!(MyApp, dir = "api"); - let input: ExportAppInput = syn::parse2(tokens).unwrap(); - assert_eq!(input.name.to_string(), "MyApp"); - assert_eq!(input.dir.unwrap().value(), "api"); - } - - #[test] - fn test_export_app_input_with_trailing_comma() { - let tokens = quote::quote!(MyApp,); - let input: ExportAppInput = syn::parse2(tokens).unwrap(); - assert_eq!(input.name.to_string(), "MyApp"); - assert!(input.dir.is_none()); - } - - #[test] - fn test_export_app_input_unknown_field() { - let tokens = quote::quote!(MyApp, unknown = "value"); - let result: syn::Result = syn::parse2(tokens); - assert!(result.is_err()); - let err = result.err().unwrap(); - assert!(err.to_compile_error().to_string().contains("unknown field")); - } - - #[test] - fn test_export_app_input_multiple_commas() { - let tokens = quote::quote!(MyApp, dir = "api",); - let input: ExportAppInput = syn::parse2(tokens).unwrap(); - assert_eq!(input.name.to_string(), "MyApp"); - assert_eq!(input.dir.unwrap().value(), "api"); - } - - // ========== Tests for env var fallbacks (lines 181-183) ========== - // Note: These tests use env vars which are global state. - // The tests are designed to be resilient to parallel test execution. - - #[test] - fn test_auto_router_input_server_env_var_fallback() { - // Test lines 181-183: VESPERA_SERVER_URL env var fallback - // This test verifies the code path but may be affected by parallel tests - // Using a unique test URL to reduce collision chances - let test_url = "https://vespera-test-unique-12345.example.com"; - let test_desc = "Vespera Test Server 12345"; - - // Save current state - let old_server_url = std::env::var("VESPERA_SERVER_URL").ok(); - let old_server_desc = std::env::var("VESPERA_SERVER_DESCRIPTION").ok(); - - // SAFETY: Single-threaded test context - unsafe { - std::env::set_var("VESPERA_SERVER_URL", test_url); - std::env::set_var("VESPERA_SERVER_DESCRIPTION", test_desc); - } - - // Parse empty input - should pick up env vars - let tokens = quote::quote!(); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - - // Restore env vars immediately after parsing - unsafe { - if let Some(url) = old_server_url { - std::env::set_var("VESPERA_SERVER_URL", url); - } else { - std::env::remove_var("VESPERA_SERVER_URL"); - } - if let Some(desc) = old_server_desc { - std::env::set_var("VESPERA_SERVER_DESCRIPTION", desc); - } else { - std::env::remove_var("VESPERA_SERVER_DESCRIPTION"); - } - } - - // Check if servers was set - may not be if another test interfered - if let Some(servers) = input.servers { - // If we got servers, verify they match our test values - if servers.len() == 1 && servers[0].url == test_url { - assert_eq!(servers[0].description, Some(test_desc.to_string())); - } - // Otherwise another test's values were picked up, which is fine - } - // If servers is None, another test may have cleared the env var - acceptable - } - - #[test] - fn test_auto_router_input_server_env_var_invalid_url_filtered() { - // Test that invalid URLs (not http/https) are filtered out by the .filter() call - // This exercises the filter branch, not lines 181-183 directly - let old_server_url = std::env::var("VESPERA_SERVER_URL").ok(); - - // SAFETY: Single-threaded test context - unsafe { - std::env::set_var("VESPERA_SERVER_URL", "ftp://invalid-url-test.com"); - } - - let tokens = quote::quote!(); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); +//! Public API is re-exported from child modules to preserve +//! `crate::router_codegen::...` call paths. - // Restore env var - unsafe { - if let Some(url) = old_server_url { - std::env::set_var("VESPERA_SERVER_URL", url); - } else { - std::env::remove_var("VESPERA_SERVER_URL"); - } - } +mod docs; +mod export; +mod generator; +mod input; - // If servers is Some, it means another test set a valid URL - acceptable - // If servers is None, our invalid URL was correctly filtered - if let Some(servers) = &input.servers { - // Another test set a valid URL, check it's not our invalid one - assert!( - servers.is_empty() || servers[0].url != "ftp://invalid-url-test.com", - "Invalid ftp:// URL should have been filtered" - ); - } - } -} +pub use export::ExportAppInput; +pub use generator::generate_router_code; +pub use input::{AutoRouterInput, ProcessedVesperaInput, process_vespera_input}; diff --git a/crates/vespera_macro/src/router_codegen/codegen.rs b/crates/vespera_macro/src/router_codegen/codegen.rs new file mode 100644 index 00000000..1d473430 --- /dev/null +++ b/crates/vespera_macro/src/router_codegen/codegen.rs @@ -0,0 +1,926 @@ +//! Router `TokenStream` generation. +//! +//! Owns the Swagger / ReDoc HTML templates, the cron-scheduler spawn code, +//! and [`generate_router_code`] — the function that stitches collected route +//! metadata into an `axum::Router` literal. + +use proc_macro2::Span; +use quote::quote; +use vespera_core::route::HttpMethod; + +use crate::{ + metadata::{CollectedMetadata, CronMetadata}, + method::http_method_to_token_stream, +}; + +/// Swagger UI HTML template. Contains `{}` format placeholder for the OpenAPI spec JSON. +const SWAGGER_UI_HTML: &str = r##"Swagger UI
"##; + +/// ReDoc HTML template. Contains `{}` format placeholder for the OpenAPI spec JSON. +const REDOC_HTML: &str = r#"ReDoc
"#; + +/// Generate a documentation route handler (Swagger UI or ReDoc). +/// +/// When `has_merge` is true, the handler merges specs from child apps at runtime. +/// When false, it serves the spec directly from the compile-time constant. +fn generate_docs_route_tokens( + url: &str, + html_template: &str, + merge_spec_code: &[proc_macro2::TokenStream], + has_merge: bool, +) -> proc_macro2::TokenStream { + let method_path = http_method_to_token_stream(HttpMethod::Get); + + if has_merge { + quote!( + .route(#url, #method_path(|| async { + static MERGED_SPEC: std::sync::OnceLock = std::sync::OnceLock::new(); + let spec = MERGED_SPEC.get_or_init(|| { + let mut merged: vespera::OpenApi = vespera::serde_json::from_str(__VESPERA_SPEC).unwrap(); + #(#merge_spec_code)* + vespera::serde_json::to_string(&merged).unwrap() + }); + static HTML: std::sync::OnceLock = std::sync::OnceLock::new(); + let html = HTML.get_or_init(|| { + format!(#html_template, spec) + }); + vespera::axum::response::Html(html.as_str()) + })) + ) + } else { + quote!( + .route(#url, #method_path(|| async { + static HTML: std::sync::OnceLock = std::sync::OnceLock::new(); + let html = HTML.get_or_init(|| { + format!(#html_template, __VESPERA_SPEC) + }); + vespera::axum::response::Html(html.as_str()) + })) + ) + } +} + +/// Generate cron scheduler spawn code from collected cron metadata. +fn generate_cron_scheduler_code(cron_jobs: &[CronMetadata]) -> proc_macro2::TokenStream { + if cron_jobs.is_empty() { + return quote!(); + } + + let job_additions: Vec = cron_jobs + .iter() + .map(|cron| { + let expression = &cron.expression; + let module_path = &cron.module_path; + let function_name = &cron.function_name; + + // Build the full path: crate::module::function + let mut p: syn::punctuated::Punctuated = + syn::punctuated::Punctuated::new(); + p.push(syn::PathSegment { + ident: syn::Ident::new("crate", Span::call_site()), + arguments: syn::PathArguments::None, + }); + p.extend(module_path.split("::").filter_map(|s| { + if s.is_empty() { + None + } else { + Some(syn::PathSegment { + ident: syn::Ident::new(s, Span::call_site()), + arguments: syn::PathArguments::None, + }) + } + })); + let func_ident = syn::Ident::new(function_name, Span::call_site()); + + let err_create = format!("vespera: failed to create cron job '{function_name}'"); + let err_add = format!("vespera: failed to add cron job '{function_name}'"); + + quote! { + __vespera_cron_scheduler.add( + vespera::tokio_cron_scheduler::Job::new_async(#expression, |_uuid, _l| { + Box::pin(async move { + #p::#func_ident().await; + }) + }).expect(#err_create) + ).await.expect(#err_add); + } + }) + .collect(); + + quote! { + vespera::tokio::spawn(async move { + let mut __vespera_cron_scheduler = vespera::tokio_cron_scheduler::JobScheduler::new().await + .expect("vespera: failed to create cron scheduler"); + #(#job_additions)* + __vespera_cron_scheduler.start().await + .expect("vespera: failed to start cron scheduler"); + // Keep scheduler alive forever + ::std::future::pending::<()>().await; + }); + } +} + +/// Generate Axum router code from collected metadata +#[allow(clippy::too_many_lines)] +pub fn generate_router_code( + metadata: &CollectedMetadata, + docs_url: Option<&str>, + redoc_url: Option<&str>, + spec_tokens: Option, + merge_apps: &[syn::Path], + cron_jobs: &[CronMetadata], +) -> proc_macro2::TokenStream { + let mut router_nests = Vec::new(); + + for route in &metadata.routes { + let Ok(http_method) = HttpMethod::try_from(route.method.as_str()) else { + eprintln!( + "vespera: skipping route '{}' — unknown HTTP method '{}'", + route.path, route.method + ); + continue; + }; + let method_path = http_method_to_token_stream(http_method); + let path = &route.path; + let module_path = &route.module_path; + let function_name = &route.function_name; + + let mut p: syn::punctuated::Punctuated = + syn::punctuated::Punctuated::new(); + p.push(syn::PathSegment { + ident: syn::Ident::new("crate", Span::call_site()), + arguments: syn::PathArguments::None, + }); + p.extend(module_path.split("::").filter_map(|s| { + if s.is_empty() { + None + } else { + Some(syn::PathSegment { + ident: syn::Ident::new(s, Span::call_site()), + arguments: syn::PathArguments::None, + }) + } + })); + let func_name = syn::Ident::new(function_name, Span::call_site()); + router_nests.push(quote!( + .route(#path, #method_path(#p::#func_name)) + )); + } + + // Check if we need to merge specs at runtime + let has_merge = !merge_apps.is_empty(); + + // Generate merge code once, reuse in both docs_url and redoc_url routes + let merge_spec_code: Vec<_> = merge_apps + .iter() + .map(|app_path| { + quote! { + if let Ok(other) = vespera::serde_json::from_str::(#app_path::OPENAPI_SPEC) { + merged.merge(other); + } + } + }) + .collect(); + + if let Some(docs_url) = docs_url { + router_nests.push(generate_docs_route_tokens( + docs_url, + SWAGGER_UI_HTML, + &merge_spec_code, + has_merge, + )); + } + + if let Some(redoc_url) = redoc_url { + router_nests.push(generate_docs_route_tokens( + redoc_url, + REDOC_HTML, + &merge_spec_code, + has_merge, + )); + } + + let needs_spec_const = spec_tokens.is_some() && (docs_url.is_some() || redoc_url.is_some()); + let cron_code = generate_cron_scheduler_code(cron_jobs); + + if needs_spec_const { + let spec_expr = spec_tokens.unwrap(); + if merge_apps.is_empty() { + quote! { + { + const __VESPERA_SPEC: &str = #spec_expr; + #cron_code + vespera::axum::Router::new() + #( #router_nests )* + } + } + } else { + quote! { + { + const __VESPERA_SPEC: &str = #spec_expr; + #cron_code + vespera::VesperaRouter::new( + vespera::axum::Router::new() + #( #router_nests )*, + vec![#( #merge_apps::router ),*] + ) + } + } + } + } else if merge_apps.is_empty() { + if cron_jobs.is_empty() { + quote! { + vespera::axum::Router::new() + #( #router_nests )* + } + } else { + quote! { + { + #cron_code + vespera::axum::Router::new() + #( #router_nests )* + } + } + } + } else { + // When merging apps, return VesperaRouter which defers the merge + // until with_state() is called. This is necessary because Axum requires + // merged routers to have the same state type. + if cron_jobs.is_empty() { + quote! { + vespera::VesperaRouter::new( + vespera::axum::Router::new() + #( #router_nests )*, + vec![#( #merge_apps::router ),*] + ) + } + } else { + quote! { + { + #cron_code + vespera::VesperaRouter::new( + vespera::axum::Router::new() + #( #router_nests )*, + vec![#( #merge_apps::router ),*] + ) + } + } + } + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use rstest::rstest; + use tempfile::TempDir; + + use super::*; + use crate::collector::collect_metadata; + + fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> std::path::PathBuf { + let file_path = dir.path().join(filename); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).expect("Failed to create parent directory"); + } + fs::write(&file_path, content).expect("Failed to write temp file"); + file_path + } + + // ===== Empty / basic routers ===== + + #[test] + fn test_generate_router_code_empty() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name, &[]) + .unwrap() + .0, + None, + None, + None, + &[], + &[], + ); + let code = result.to_string(); + + assert!( + code.contains("Router") && code.contains("new"), + "Code should contain Router::new(), got: {code}" + ); + assert!( + !code.contains("route"), + "Code should not contain route, got: {code}" + ); + + drop(temp_dir); + } + + /// Render the standard single-route fixture file body. + fn route_src(route_attr: &str, fn_name: &str) -> String { + format!("\n#[route({route_attr})]\npub fn {fn_name}() -> String {{\n\"x\".to_string()\n}}\n") + } + + #[rstest] + #[case::single_get_route("users.rs", "get", "get_users", "get", "/users", "routes::users::get_users")] + #[case::single_post_route("create_user.rs", "post", "create_user", "post", "/create-user", "routes::create_user::create_user")] + #[case::single_put_route("update_user.rs", "put", "update_user", "put", "/update-user", "routes::update_user::update_user")] + #[case::single_delete_route("delete_user.rs", "delete", "delete_user", "delete", "/delete-user", "routes::delete_user::delete_user")] + #[case::single_patch_route("patch_user.rs", "patch", "patch_user", "patch", "/patch-user", "routes::patch_user::patch_user")] + #[case::route_with_custom_path("users.rs", r#"get, path = "/api/users""#, "get_users", "get", "/users/api/users", "routes::users::get_users")] + #[case::nested_module("api/users.rs", "get", "get_users", "get", "/api/users", "routes::api::users::get_users")] + #[case::deeply_nested_module("api/v1/users.rs", "get", "get_users", "get", "/api/v1/users", "routes::api::v1::users::get_users")] + fn test_generate_router_code_single_route( + #[case] filename: &str, + #[case] route_attr: &str, + #[case] fn_name: &str, + #[case] expected_method: &str, + #[case] expected_path: &str, + #[case] expected_function_path: &str, + ) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + create_temp_file(&temp_dir, filename, &route_src(route_attr, fn_name)); + + let result = generate_router_code( + &collect_metadata(temp_dir.path(), "routes", &[]) + .unwrap() + .0, + None, + None, + None, + &[], + &[], + ); + let code = result.to_string(); + + assert!( + code.contains("Router") && code.contains("new"), + "Code should contain Router::new(), got: {code}" + ); + + assert!( + code.contains(expected_method), + "Code should contain method: {expected_method}, got: {code}" + ); + + assert!( + code.contains(expected_path), + "Code should contain path: {expected_path}, got: {code}" + ); + + let function_parts: Vec<&str> = expected_function_path.split("::").collect(); + for part in &function_parts { + if !part.is_empty() { + assert!( + code.contains(part), + "Code should contain function part: {part}, got: {code}" + ); + } + } + + drop(temp_dir); + } + + #[test] + fn test_generate_router_code_multiple_routes() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { +"users".to_string() +} +"#, + ); + + create_temp_file( + &temp_dir, + "create_user.rs", + r#" +#[route(post)] +pub fn create_user() -> String { +"created".to_string() +} +"#, + ); + + create_temp_file( + &temp_dir, + "update_user.rs", + r#" +#[route(put)] +pub fn update_user() -> String { +"updated".to_string() +} +"#, + ); + + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name, &[]) + .unwrap() + .0, + None, + None, + None, + &[], + &[], + ); + let code = result.to_string(); + + assert!(code.contains("Router") && code.contains("new")); + + assert!(code.contains("get_users")); + assert!(code.contains("create_user")); + assert!(code.contains("update_user")); + + assert!(code.contains("get")); + assert!(code.contains("post")); + assert!(code.contains("put")); + + let route_count = code.matches(". route (").count(); + assert_eq!( + route_count, 3, + "Should have 3 route calls, got: {route_count}, code: {code}" + ); + + drop(temp_dir); + } + + #[test] + fn test_generate_router_code_same_path_different_methods() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { +"users".to_string() +} + +#[route(post)] +pub fn create_users() -> String { +"created".to_string() +} +"#, + ); + + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name, &[]) + .unwrap() + .0, + None, + None, + None, + &[], + &[], + ); + let code = result.to_string(); + + assert!(code.contains("Router") && code.contains("new")); + + assert!(code.contains("get_users")); + assert!(code.contains("create_users")); + + assert!(code.contains("get")); + assert!(code.contains("post")); + + let route_count = code.matches(". route (").count(); + assert_eq!( + route_count, 2, + "Should have 2 routes, got: {route_count}, code: {code}" + ); + + drop(temp_dir); + } + + #[test] + fn test_generate_router_code_with_mod_rs() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "mod.rs", + r#" +#[route(get)] +pub fn index() -> String { +"index".to_string() +} +"#, + ); + + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name, &[]) + .unwrap() + .0, + None, + None, + None, + &[], + &[], + ); + let code = result.to_string(); + + assert!(code.contains("Router") && code.contains("new")); + assert!(code.contains("index")); + assert!(code.contains("\"/\"")); + + drop(temp_dir); + } + + #[test] + fn test_generate_router_code_empty_folder_name() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = ""; + + create_temp_file( + &temp_dir, + "users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { +"users".to_string() +} +"#, + ); + + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name, &[]) + .unwrap() + .0, + None, + None, + None, + &[], + &[], + ); + let code = result.to_string(); + + assert!(code.contains("Router") && code.contains("new")); + assert!(code.contains("get_users")); + assert!(!code.contains("::users::users")); + + drop(temp_dir); + } + + // ===== Docs & redoc routes ===== + + #[test] + fn test_generate_router_code_with_docs() { + let metadata = CollectedMetadata::new(); + let spec = r#"{"openapi":"3.1.0"}"#; + + let result = generate_router_code( + &metadata, + Some("/docs"), + None, + Some(quote::quote!(#spec)), + &[], + &[], + ); + let code = result.to_string(); + + assert!(code.contains("/docs")); + assert!(code.contains("swagger-ui")); + assert!(code.contains("__VESPERA_SPEC")); + assert!(code.contains("OnceLock")); + } + + #[test] + fn test_generate_router_code_with_redoc() { + let metadata = CollectedMetadata::new(); + let spec = r#"{"openapi":"3.1.0"}"#; + + let result = generate_router_code( + &metadata, + None, + Some("/redoc"), + Some(quote::quote!(#spec)), + &[], + &[], + ); + let code = result.to_string(); + + assert!(code.contains("/redoc")); + assert!(code.contains("redoc")); + assert!(code.contains("__VESPERA_SPEC")); + assert!(code.contains("OnceLock")); + } + + #[test] + fn test_generate_router_code_with_both_docs() { + let metadata = CollectedMetadata::new(); + let spec = r#"{"openapi":"3.1.0"}"#; + + let result = generate_router_code( + &metadata, + Some("/docs"), + Some("/redoc"), + Some(quote::quote!(#spec)), + &[], + &[], + ); + let code = result.to_string(); + + assert!(code.contains("/docs")); + assert!(code.contains("/redoc")); + assert!(code.contains("__VESPERA_SPEC")); + } + + #[test] + fn test_swagger_html_template_renders_valid_quotes() { + assert!( + !SWAGGER_UI_HTML.contains(r#"\""#), + "Swagger template should not contain literal backslash-quotes: {SWAGGER_UI_HTML}" + ); + assert!( + SWAGGER_UI_HTML.contains(r#"href="https://unpkg.com/swagger-ui-dist/swagger-ui.css""#) + ); + assert!( + SWAGGER_UI_HTML + .contains(r#"src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js""#) + ); + assert!(SWAGGER_UI_HTML.contains(r##"dom_id: "#swagger-ui""##)); + } + + #[test] + fn test_redoc_html_template_renders_valid_quotes() { + assert!( + !REDOC_HTML.contains(r#"\""#), + "ReDoc template should not contain literal backslash-quotes: {REDOC_HTML}" + ); + assert!( + REDOC_HTML.contains(r#"href="https://unpkg.com/redoc/bundles/redoc.standalone.css""#) + ); + assert!(REDOC_HTML.contains(r#"src="https://unpkg.com/redoc/bundles/redoc.standalone.js""#)); + assert!(REDOC_HTML.contains(r#"document.getElementById("redoc-container")"#)); + } + + // ===== Unknown method / route skipping ===== + + #[test] + fn test_generate_router_code_unknown_http_method() { + let mut metadata = CollectedMetadata { + routes: Vec::new(), + structs: Vec::new(), + crons: Vec::new(), + }; + metadata.routes.push(crate::metadata::RouteMetadata { + method: "INVALID".to_string(), + path: "/users".to_string(), + function_name: "get_users".to_string(), + module_path: "routes::users".to_string(), + file_path: "dummy.rs".to_string(), + error_status: None, + tags: None, + description: None, + }); + + let result = generate_router_code(&metadata, None, None, None, &[], &[]); + let code = result.to_string(); + + assert!( + code.contains("Router") && code.contains("new"), + "Code should contain Router::new(), got: {code}" + ); + assert!( + !code.contains(". route ("), + "Route with unknown HTTP method should be skipped, got: {code}" + ); + } + + #[test] + fn test_generate_router_code_unknown_method_skipped_valid_kept() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { +"users".to_string() +} +"#, + ); + + let (mut metadata, _file_asts) = + collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); + metadata.routes.push(crate::metadata::RouteMetadata { + method: "CONNECT".to_string(), + path: "/invalid".to_string(), + function_name: "connect_handler".to_string(), + module_path: "routes::invalid".to_string(), + file_path: "dummy.rs".to_string(), + error_status: None, + tags: None, + description: None, + }); + + let result = generate_router_code(&metadata, None, None, None, &[], &[]); + let code = result.to_string(); + + assert!( + code.contains("get_users"), + "Valid route should be present, got: {code}" + ); + assert!( + !code.contains("connect_handler"), + "Invalid method route should be skipped, got: {code}" + ); + + drop(temp_dir); + } + + // ===== Merge apps ===== + + #[test] + fn test_generate_router_code_with_merge_apps() { + let metadata = CollectedMetadata::new(); + let merge_apps: Vec = vec![syn::parse_quote!(third::ThirdApp)]; + + let result = generate_router_code(&metadata, None, None, None, &merge_apps, &[]); + let code = result.to_string(); + + assert!( + code.contains("VesperaRouter"), + "Should use VesperaRouter for merge, got: {code}" + ); + assert!( + code.contains("third :: ThirdApp") || code.contains("third::ThirdApp"), + "Should reference merged app, got: {code}" + ); + } + + #[test] + fn test_generate_router_code_with_docs_and_merge() { + let metadata = CollectedMetadata::new(); + let spec = r#"{"openapi":"3.1.0"}"#; + let merge_apps: Vec = vec![syn::parse_quote!(app::MyApp)]; + + let result = generate_router_code( + &metadata, + Some("/docs"), + None, + Some(quote::quote!(#spec)), + &merge_apps, + &[], + ); + let code = result.to_string(); + + assert!( + code.contains("OnceLock"), + "Should use OnceLock for merged docs, got: {code}" + ); + assert!( + code.contains("MERGED_SPEC"), + "Should have MERGED_SPEC, got: {code}" + ); + assert!( + code.contains("merged . merge") || code.contains("merged.merge"), + "Should call merge on spec, got: {code}" + ); + } + + #[test] + fn test_generate_router_code_with_redoc_and_merge() { + let metadata = CollectedMetadata::new(); + let spec = r#"{"openapi":"3.1.0"}"#; + let merge_apps: Vec = vec![syn::parse_quote!(other::OtherApp)]; + + let result = generate_router_code( + &metadata, + None, + Some("/redoc"), + Some(quote::quote!(#spec)), + &merge_apps, + &[], + ); + let code = result.to_string(); + + assert!( + code.contains("OnceLock"), + "Should use OnceLock for merged redoc" + ); + assert!(code.contains("redoc"), "Should contain redoc"); + } + + #[test] + fn test_generate_router_code_with_both_docs_and_merge() { + let metadata = CollectedMetadata::new(); + let spec = r#"{"openapi":"3.1.0"}"#; + let merge_apps: Vec = vec![syn::parse_quote!(merged::App)]; + + let result = generate_router_code( + &metadata, + Some("/docs"), + Some("/redoc"), + Some(quote::quote!(#spec)), + &merge_apps, + &[], + ); + let code = result.to_string(); + + let merged_spec_count = code.matches("MERGED_SPEC").count(); + assert!( + merged_spec_count >= 2, + "Should have at least 2 MERGED_SPEC for docs and redoc, got: {merged_spec_count}" + ); + let vespera_spec_count = code.matches("__VESPERA_SPEC").count(); + assert!( + vespera_spec_count >= 1, + "Should have __VESPERA_SPEC const, got: {vespera_spec_count}" + ); + assert!( + code.contains("/docs") && code.contains("/redoc"), + "Should contain both /docs and /redoc" + ); + } + + #[test] + fn test_generate_router_code_with_multiple_merge_apps() { + let metadata = CollectedMetadata::new(); + let merge_apps: Vec = vec![ + syn::parse_quote!(first::App), + syn::parse_quote!(second::App), + ]; + + let result = generate_router_code(&metadata, None, None, None, &merge_apps, &[]); + let code = result.to_string(); + + assert!( + code.contains("first") && code.contains("second"), + "Should reference both merge apps, got: {code}" + ); + } + + // ===== Cron jobs ===== + + #[test] + fn test_generate_router_code_with_merge_and_cron() { + let metadata = CollectedMetadata::new(); + let merge_apps: Vec = vec![syn::parse_quote!(third::ThirdApp)]; + let cron_jobs = vec![CronMetadata { + expression: "0 */5 * * * *".to_string(), + function_name: "cleanup".to_string(), + module_path: "tasks".to_string(), + file_path: "src/tasks.rs".to_string(), + }]; + + let result = + generate_router_code(&metadata, None, None, None, &merge_apps, &cron_jobs); + let code = result.to_string(); + + assert!( + code.contains("VesperaRouter"), + "Should use VesperaRouter for merge, got: {code}" + ); + assert!( + code.contains("JobScheduler"), + "Should contain cron scheduler code, got: {code}" + ); + assert!( + code.contains("cleanup"), + "Should reference cron function, got: {code}" + ); + } + + #[test] + fn test_generate_router_code_with_cron_no_merge() { + let metadata = CollectedMetadata::new(); + let cron_jobs = vec![CronMetadata { + expression: "1/10 * * * * *".to_string(), + function_name: "heartbeat".to_string(), + module_path: "cron::health".to_string(), + file_path: "src/cron/health.rs".to_string(), + }]; + + let result = generate_router_code(&metadata, None, None, None, &[], &cron_jobs); + let code = result.to_string(); + + assert!( + !code.contains("VesperaRouter"), + "Should NOT use VesperaRouter without merge, got: {code}" + ); + assert!( + code.contains("JobScheduler"), + "Should contain cron scheduler code, got: {code}" + ); + assert!( + code.contains("heartbeat"), + "Should reference cron function, got: {code}" + ); + } +} diff --git a/crates/vespera_macro/src/router_codegen/docs.rs b/crates/vespera_macro/src/router_codegen/docs.rs new file mode 100644 index 00000000..802d1f85 --- /dev/null +++ b/crates/vespera_macro/src/router_codegen/docs.rs @@ -0,0 +1,87 @@ +use quote::quote; +use vespera_core::route::HttpMethod; + +use crate::method::http_method_to_token_stream; + +/// Swagger UI HTML template. Contains `{}` format placeholder for the OpenAPI spec JSON. +pub(super) const SWAGGER_UI_HTML: &str = r##"Swagger UI
"##; + +/// ReDoc HTML template. Contains `{}` format placeholder for the OpenAPI spec JSON. +pub(super) const REDOC_HTML: &str = r#"ReDoc
"#; + +/// Generate a documentation route handler (Swagger UI or ReDoc). +/// +/// When `has_merge` is true, the handler merges specs from child apps at runtime. +/// When false, it serves the spec directly from the compile-time constant. +pub(super) fn generate_docs_route_tokens( + url: &str, + html_template: &str, + merge_spec_code: &[proc_macro2::TokenStream], + has_merge: bool, +) -> proc_macro2::TokenStream { + let method_path = http_method_to_token_stream(HttpMethod::Get); + + if has_merge { + quote!( + .route(#url, #method_path(|| async { + static MERGED_SPEC: std::sync::OnceLock = std::sync::OnceLock::new(); + let spec = MERGED_SPEC.get_or_init(|| { + let mut merged: vespera::OpenApi = vespera::serde_json::from_str(__VESPERA_SPEC).unwrap(); + #(#merge_spec_code)* + vespera::serde_json::to_string(&merged).unwrap() + }); + static HTML: std::sync::OnceLock = std::sync::OnceLock::new(); + let html = HTML.get_or_init(|| { + format!(#html_template, spec) + }); + vespera::axum::response::Html(html.as_str()) + })) + ) + } else { + quote!( + .route(#url, #method_path(|| async { + static HTML: std::sync::OnceLock = std::sync::OnceLock::new(); + let html = HTML.get_or_init(|| { + format!(#html_template, __VESPERA_SPEC) + }); + vespera::axum::response::Html(html.as_str()) + })) + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_swagger_html_template_renders_valid_quotes() { + assert!( + !SWAGGER_UI_HTML.contains(r#"\""#), + "Swagger template should not contain literal backslash-quotes: {SWAGGER_UI_HTML}" + ); + assert!( + SWAGGER_UI_HTML.contains(r#"href="https://unpkg.com/swagger-ui-dist/swagger-ui.css""#) + ); + assert!( + SWAGGER_UI_HTML + .contains(r#"src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js""#) + ); + assert!(SWAGGER_UI_HTML.contains(r##"dom_id: "#swagger-ui""##)); + } + + #[test] + fn test_redoc_html_template_renders_valid_quotes() { + assert!( + !REDOC_HTML.contains(r#"\""#), + "ReDoc template should not contain literal backslash-quotes: {REDOC_HTML}" + ); + assert!( + REDOC_HTML.contains(r#"href="https://unpkg.com/redoc/bundles/redoc.standalone.css""#) + ); + assert!( + REDOC_HTML.contains(r#"src="https://unpkg.com/redoc/bundles/redoc.standalone.js""#) + ); + assert!(REDOC_HTML.contains(r#"document.getElementById("redoc-container")"#)); + } +} diff --git a/crates/vespera_macro/src/router_codegen/export.rs b/crates/vespera_macro/src/router_codegen/export.rs new file mode 100644 index 00000000..495e7bd6 --- /dev/null +++ b/crates/vespera_macro/src/router_codegen/export.rs @@ -0,0 +1,93 @@ +use syn::{ + LitStr, + parse::{Parse, ParseStream}, +}; + +/// Input for `export_app`! macro +pub struct ExportAppInput { + /// App name (struct name to generate) + pub name: syn::Ident, + /// Route directory + pub dir: Option, +} + +impl Parse for ExportAppInput { + fn parse(input: ParseStream) -> syn::Result { + let name: syn::Ident = input.parse()?; + + let mut dir = None; + + // Parse optional comma and arguments + while input.peek(syn::Token![,]) { + input.parse::()?; + + if input.is_empty() { + break; + } + + let ident: syn::Ident = input.parse()?; + let ident_str = ident.to_string(); + + match ident_str.as_str() { + "dir" => { + input.parse::()?; + dir = Some(input.parse()?); + } + _ => { + return Err(syn::Error::new( + ident.span(), + format!("unknown field: `{ident_str}`. Expected `dir`"), + )); + } + } + } + + Ok(Self { name, dir }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_export_app_input_name_only() { + let tokens = quote::quote!(MyApp); + let input: ExportAppInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.name.to_string(), "MyApp"); + assert!(input.dir.is_none()); + } + + #[test] + fn test_export_app_input_with_dir() { + let tokens = quote::quote!(MyApp, dir = "api"); + let input: ExportAppInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.name.to_string(), "MyApp"); + assert_eq!(input.dir.unwrap().value(), "api"); + } + + #[test] + fn test_export_app_input_with_trailing_comma() { + let tokens = quote::quote!(MyApp,); + let input: ExportAppInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.name.to_string(), "MyApp"); + assert!(input.dir.is_none()); + } + + #[test] + fn test_export_app_input_unknown_field() { + let tokens = quote::quote!(MyApp, unknown = "value"); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err()); + let err = result.err().unwrap(); + assert!(err.to_compile_error().to_string().contains("unknown field")); + } + + #[test] + fn test_export_app_input_multiple_commas() { + let tokens = quote::quote!(MyApp, dir = "api",); + let input: ExportAppInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.name.to_string(), "MyApp"); + assert_eq!(input.dir.unwrap().value(), "api"); + } +} diff --git a/crates/vespera_macro/src/router_codegen/generator.rs b/crates/vespera_macro/src/router_codegen/generator.rs new file mode 100644 index 00000000..589bf86f --- /dev/null +++ b/crates/vespera_macro/src/router_codegen/generator.rs @@ -0,0 +1,989 @@ +use proc_macro2::Span; +use quote::quote; +use vespera_core::route::HttpMethod; + +use crate::{ + metadata::{CollectedMetadata, CronMetadata}, + method::http_method_to_token_stream, +}; + +use super::docs::{REDOC_HTML, SWAGGER_UI_HTML, generate_docs_route_tokens}; + +/// Generate cron scheduler spawn code from collected cron metadata. +fn generate_cron_scheduler_code(cron_jobs: &[CronMetadata]) -> proc_macro2::TokenStream { + if cron_jobs.is_empty() { + return quote!(); + } + + let job_additions: Vec = cron_jobs + .iter() + .map(|cron| { + let expression = &cron.expression; + let module_path = &cron.module_path; + let function_name = &cron.function_name; + + // Build the full path: crate::module::function + let mut p: syn::punctuated::Punctuated = + syn::punctuated::Punctuated::new(); + p.push(syn::PathSegment { + ident: syn::Ident::new("crate", Span::call_site()), + arguments: syn::PathArguments::None, + }); + p.extend(module_path.split("::").filter_map(|s| { + if s.is_empty() { + None + } else { + Some(syn::PathSegment { + ident: syn::Ident::new(s, Span::call_site()), + arguments: syn::PathArguments::None, + }) + } + })); + let func_ident = syn::Ident::new(function_name, Span::call_site()); + + let err_create = format!("vespera: failed to create cron job '{function_name}'"); + let err_add = format!("vespera: failed to add cron job '{function_name}'"); + + quote! { + __vespera_cron_scheduler.add( + vespera::tokio_cron_scheduler::Job::new_async(#expression, |_uuid, _l| { + Box::pin(async move { + #p::#func_ident().await; + }) + }).expect(#err_create) + ).await.expect(#err_add); + } + }) + .collect(); + + quote! { + vespera::tokio::spawn(async move { + let mut __vespera_cron_scheduler = vespera::tokio_cron_scheduler::JobScheduler::new().await + .expect("vespera: failed to create cron scheduler"); + #(#job_additions)* + __vespera_cron_scheduler.start().await + .expect("vespera: failed to start cron scheduler"); + // Keep scheduler alive forever + ::std::future::pending::<()>().await; + }); + } +} + +/// Generate Axum router code from collected metadata +#[allow(clippy::too_many_lines)] +pub fn generate_router_code( + metadata: &CollectedMetadata, + docs_url: Option<&str>, + redoc_url: Option<&str>, + spec_tokens: Option, + merge_apps: &[syn::Path], + cron_jobs: &[CronMetadata], +) -> proc_macro2::TokenStream { + let mut router_nests = Vec::new(); + + for route in &metadata.routes { + let Ok(http_method) = HttpMethod::try_from(route.method.as_str()) else { + eprintln!( + "vespera: skipping route '{}' — unknown HTTP method '{}'", + route.path, route.method + ); + continue; + }; + let method_path = http_method_to_token_stream(http_method); + let path = &route.path; + let module_path = &route.module_path; + let function_name = &route.function_name; + + let mut p: syn::punctuated::Punctuated = + syn::punctuated::Punctuated::new(); + p.push(syn::PathSegment { + ident: syn::Ident::new("crate", Span::call_site()), + arguments: syn::PathArguments::None, + }); + p.extend(module_path.split("::").filter_map(|s| { + if s.is_empty() { + None + } else { + Some(syn::PathSegment { + ident: syn::Ident::new(s, Span::call_site()), + arguments: syn::PathArguments::None, + }) + } + })); + let func_name = syn::Ident::new(function_name, Span::call_site()); + router_nests.push(quote!( + .route(#path, #method_path(#p::#func_name)) + )); + } + + // Check if we need to merge specs at runtime + let has_merge = !merge_apps.is_empty(); + + // Generate merge code once, reuse in both docs_url and redoc_url routes + let merge_spec_code: Vec<_> = merge_apps + .iter() + .map(|app_path| { + quote! { + if let Ok(other) = vespera::serde_json::from_str::(#app_path::OPENAPI_SPEC) { + merged.merge(other); + } + } + }) + .collect(); + + if let Some(docs_url) = docs_url { + router_nests.push(generate_docs_route_tokens( + docs_url, + SWAGGER_UI_HTML, + &merge_spec_code, + has_merge, + )); + } + + if let Some(redoc_url) = redoc_url { + router_nests.push(generate_docs_route_tokens( + redoc_url, + REDOC_HTML, + &merge_spec_code, + has_merge, + )); + } + + let needs_spec_const = spec_tokens.is_some() && (docs_url.is_some() || redoc_url.is_some()); + let cron_code = generate_cron_scheduler_code(cron_jobs); + + if needs_spec_const { + let spec_expr = spec_tokens.unwrap(); + if merge_apps.is_empty() { + quote! { + { + const __VESPERA_SPEC: &str = #spec_expr; + #cron_code + vespera::axum::Router::new() + #( #router_nests )* + } + } + } else { + quote! { + { + const __VESPERA_SPEC: &str = #spec_expr; + #cron_code + vespera::VesperaRouter::new( + vespera::axum::Router::new() + #( #router_nests )*, + vec![#( #merge_apps::router ),*] + ) + } + } + } + } else if merge_apps.is_empty() { + if cron_jobs.is_empty() { + quote! { + vespera::axum::Router::new() + #( #router_nests )* + } + } else { + quote! { + { + #cron_code + vespera::axum::Router::new() + #( #router_nests )* + } + } + } + } else { + // When merging apps, return VesperaRouter which defers the merge + // until with_state() is called. This is necessary because Axum requires + // merged routers to have the same state type. + if cron_jobs.is_empty() { + quote! { + vespera::VesperaRouter::new( + vespera::axum::Router::new() + #( #router_nests )*, + vec![#( #merge_apps::router ),*] + ) + } + } else { + quote! { + { + #cron_code + vespera::VesperaRouter::new( + vespera::axum::Router::new() + #( #router_nests )*, + vec![#( #merge_apps::router ),*] + ) + } + } + } + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use rstest::rstest; + use tempfile::TempDir; + + use super::*; + use crate::collector::collect_metadata; + + fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> std::path::PathBuf { + let file_path = dir.path().join(filename); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).expect("Failed to create parent directory"); + } + fs::write(&file_path, content).expect("Failed to write temp file"); + file_path + } + + #[test] + fn test_generate_router_code_empty() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name, &[]) + .unwrap() + .0, + None, + None, + None, + &[], + &[], + ); + let code = result.to_string(); + + // Should generate empty router + // quote! generates "vespera :: axum :: Router :: new ()" format + assert!( + code.contains("Router") && code.contains("new"), + "Code should contain Router::new(), got: {code}" + ); + assert!( + !code.contains("route"), + "Code should not contain route, got: {code}" + ); + + drop(temp_dir); + } + + #[rstest] + #[case::single_get_route( + "routes", + vec![( + "users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { +"users".to_string() +} +"#, + )], + "get", + "/users", + "routes::users::get_users", +)] + #[case::single_post_route( + "routes", + vec![( + "create_user.rs", + r#" +#[route(post)] +pub fn create_user() -> String { +"created".to_string() +} +"#, + )], + "post", + "/create-user", + "routes::create_user::create_user", +)] + #[case::single_put_route( + "routes", + vec![( + "update_user.rs", + r#" +#[route(put)] +pub fn update_user() -> String { +"updated".to_string() +} +"#, + )], + "put", + "/update-user", + "routes::update_user::update_user", +)] + #[case::single_delete_route( + "routes", + vec![( + "delete_user.rs", + r#" +#[route(delete)] +pub fn delete_user() -> String { +"deleted".to_string() +} +"#, + )], + "delete", + "/delete-user", + "routes::delete_user::delete_user", +)] + #[case::single_patch_route( + "routes", + vec![( + "patch_user.rs", + r#" +#[route(patch)] +pub fn patch_user() -> String { +"patched".to_string() +} +"#, + )], + "patch", + "/patch-user", + "routes::patch_user::patch_user", +)] + #[case::route_with_custom_path( + "routes", + vec![( + "users.rs", + r#" +#[route(get, path = "/api/users")] +pub fn get_users() -> String { +"users".to_string() +} +"#, + )], + "get", + "/users/api/users", + "routes::users::get_users", +)] + #[case::nested_module( + "routes", + vec![( + "api/users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { +"users".to_string() +} +"#, + )], + "get", + "/api/users", + "routes::api::users::get_users", +)] + #[case::deeply_nested_module( + "routes", + vec![( + "api/v1/users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { +"users".to_string() +} +"#, + )], + "get", + "/api/v1/users", + "routes::api::v1::users::get_users", +)] + fn test_generate_router_code_single_route( + #[case] folder_name: &str, + #[case] files: Vec<(&str, &str)>, + #[case] expected_method: &str, + #[case] expected_path: &str, + #[case] expected_function_path: &str, + ) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + for (filename, content) in files { + create_temp_file(&temp_dir, filename, content); + } + + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name, &[]) + .unwrap() + .0, + None, + None, + None, + &[], + &[], + ); + let code = result.to_string(); + + // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") + assert!( + code.contains("Router") && code.contains("new"), + "Code should contain Router::new(), got: {code}" + ); + + // Check route method + assert!( + code.contains(expected_method), + "Code should contain method: {expected_method}, got: {code}" + ); + + // Check route path + assert!( + code.contains(expected_path), + "Code should contain path: {expected_path}, got: {code}" + ); + + // Check function path (quote! adds spaces, so we check for parts) + let function_parts: Vec<&str> = expected_function_path.split("::").collect(); + for part in &function_parts { + if !part.is_empty() { + assert!( + code.contains(part), + "Code should contain function part: {part}, got: {code}" + ); + } + } + + drop(temp_dir); + } + + #[test] + fn test_generate_router_code_multiple_routes() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + // Create multiple route files + create_temp_file( + &temp_dir, + "users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { +"users".to_string() +} +"#, + ); + + create_temp_file( + &temp_dir, + "create_user.rs", + r#" +#[route(post)] +pub fn create_user() -> String { +"created".to_string() +} +"#, + ); + + create_temp_file( + &temp_dir, + "update_user.rs", + r#" +#[route(put)] +pub fn update_user() -> String { +"updated".to_string() +} +"#, + ); + + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name, &[]) + .unwrap() + .0, + None, + None, + None, + &[], + &[], + ); + let code = result.to_string(); + + // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") + assert!(code.contains("Router") && code.contains("new")); + + // Check all routes are present + assert!(code.contains("get_users")); + assert!(code.contains("create_user")); + assert!(code.contains("update_user")); + + // Check methods + assert!(code.contains("get")); + assert!(code.contains("post")); + assert!(code.contains("put")); + + // Count route calls (quote! generates ". route (" with spaces) + // Count occurrences of ". route (" pattern + let route_count = code.matches(". route (").count(); + assert_eq!( + route_count, 3, + "Should have 3 route calls, got: {route_count}, code: {code}" + ); + + drop(temp_dir); + } + + #[test] + fn test_generate_router_code_same_path_different_methods() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + // Create routes with same path but different methods + create_temp_file( + &temp_dir, + "users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { +"users".to_string() +} + +#[route(post)] +pub fn create_users() -> String { +"created".to_string() +} +"#, + ); + + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name, &[]) + .unwrap() + .0, + None, + None, + None, + &[], + &[], + ); + let code = result.to_string(); + + // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") + assert!(code.contains("Router") && code.contains("new")); + + // Check both routes are present + assert!(code.contains("get_users")); + assert!(code.contains("create_users")); + + // Check methods + assert!(code.contains("get")); + assert!(code.contains("post")); + + // Should have 2 routes (quote! generates ". route (" with spaces) + let route_count = code.matches(". route (").count(); + assert_eq!( + route_count, 2, + "Should have 2 routes, got: {route_count}, code: {code}" + ); + + drop(temp_dir); + } + + #[test] + fn test_generate_router_code_with_mod_rs() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + // Create mod.rs file + create_temp_file( + &temp_dir, + "mod.rs", + r#" +#[route(get)] +pub fn index() -> String { +"index".to_string() +} +"#, + ); + + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name, &[]) + .unwrap() + .0, + None, + None, + None, + &[], + &[], + ); + let code = result.to_string(); + + // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") + assert!(code.contains("Router") && code.contains("new")); + + // Check route is present + assert!(code.contains("index")); + + // Path should be / (mod.rs maps to root, segments is empty) + // quote! generates "\"/\"" + assert!(code.contains("\"/\"")); + + drop(temp_dir); + } + + #[test] + fn test_generate_router_code_empty_folder_name() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = ""; + + create_temp_file( + &temp_dir, + "users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { +"users".to_string() +} +"#, + ); + + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name, &[]) + .unwrap() + .0, + None, + None, + None, + &[], + &[], + ); + let code = result.to_string(); + + // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") + assert!(code.contains("Router") && code.contains("new")); + + // Check route is present + assert!(code.contains("get_users")); + + // Module path should not have double colons + assert!(!code.contains("::users::users")); + + drop(temp_dir); + } + + #[test] + fn test_generate_router_code_with_docs() { + let metadata = CollectedMetadata::new(); + let spec = r#"{"openapi":"3.1.0"}"#; + + let result = generate_router_code( + &metadata, + Some("/docs"), + None, + Some(quote::quote!(#spec)), + &[], + &[], + ); + let code = result.to_string(); + + assert!(code.contains("/docs")); + assert!(code.contains("swagger-ui")); + assert!(code.contains("__VESPERA_SPEC")); + assert!(code.contains("OnceLock")); + } + + #[test] + fn test_generate_router_code_with_redoc() { + let metadata = CollectedMetadata::new(); + let spec = r#"{"openapi":"3.1.0"}"#; + + let result = generate_router_code( + &metadata, + None, + Some("/redoc"), + Some(quote::quote!(#spec)), + &[], + &[], + ); + let code = result.to_string(); + + assert!(code.contains("/redoc")); + assert!(code.contains("redoc")); + assert!(code.contains("__VESPERA_SPEC")); + assert!(code.contains("OnceLock")); + } + + #[test] + fn test_generate_router_code_with_both_docs() { + let metadata = CollectedMetadata::new(); + let spec = r#"{"openapi":"3.1.0"}"#; + + let result = generate_router_code( + &metadata, + Some("/docs"), + Some("/redoc"), + Some(quote::quote!(#spec)), + &[], + &[], + ); + let code = result.to_string(); + + assert!(code.contains("/docs")); + assert!(code.contains("/redoc")); + assert!(code.contains("__VESPERA_SPEC")); + } + + #[test] + fn test_generate_router_code_unknown_http_method() { + // Test lines 337-340: route with unknown HTTP method is skipped in router codegen + let mut metadata = CollectedMetadata { + routes: Vec::new(), + structs: Vec::new(), + crons: Vec::new(), + }; + metadata.routes.push(crate::metadata::RouteMetadata { + method: "INVALID".to_string(), + path: "/users".to_string(), + function_name: "get_users".to_string(), + module_path: "routes::users".to_string(), + file_path: "dummy.rs".to_string(), + error_status: None, + tags: None, + description: None, + }); + + let result = generate_router_code(&metadata, None, None, None, &[], &[]); + let code = result.to_string(); + + // Router should be generated but without any route calls + assert!( + code.contains("Router") && code.contains("new"), + "Code should contain Router::new(), got: {code}" + ); + assert!( + !code.contains(". route ("), + "Route with unknown HTTP method should be skipped, got: {code}" + ); + } + + #[test] + fn test_generate_router_code_unknown_method_skipped_valid_kept() { + // Test that unknown methods are skipped while valid routes are still generated + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { +"users".to_string() +} +"#, + ); + + let (mut metadata, _file_asts) = + collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); + // Inject an additional route with invalid method + metadata.routes.push(crate::metadata::RouteMetadata { + method: "CONNECT".to_string(), + path: "/invalid".to_string(), + function_name: "connect_handler".to_string(), + module_path: "routes::invalid".to_string(), + file_path: "dummy.rs".to_string(), + error_status: None, + tags: None, + description: None, + }); + + let result = generate_router_code(&metadata, None, None, None, &[], &[]); + let code = result.to_string(); + + // Valid route should be present + assert!( + code.contains("get_users"), + "Valid route should be present, got: {code}" + ); + // Invalid route should be skipped + assert!( + !code.contains("connect_handler"), + "Invalid method route should be skipped, got: {code}" + ); + + drop(temp_dir); + } + + #[test] + fn test_generate_router_code_with_merge_apps() { + let metadata = CollectedMetadata::new(); + let merge_apps: Vec = vec![syn::parse_quote!(third::ThirdApp)]; + + let result = generate_router_code(&metadata, None, None, None, &merge_apps, &[]); + let code = result.to_string(); + + // Should use VesperaRouter instead of plain Router + assert!( + code.contains("VesperaRouter"), + "Should use VesperaRouter for merge, got: {code}" + ); + assert!( + code.contains("third :: ThirdApp") || code.contains("third::ThirdApp"), + "Should reference merged app, got: {code}" + ); + } + + #[test] + fn test_generate_router_code_with_docs_and_merge() { + let metadata = CollectedMetadata::new(); + let spec = r#"{"openapi":"3.1.0"}"#; + let merge_apps: Vec = vec![syn::parse_quote!(app::MyApp)]; + + let result = generate_router_code( + &metadata, + Some("/docs"), + None, + Some(quote::quote!(#spec)), + &merge_apps, + &[], + ); + let code = result.to_string(); + + // Should have merge code for docs + assert!( + code.contains("OnceLock"), + "Should use OnceLock for merged docs, got: {code}" + ); + assert!( + code.contains("MERGED_SPEC"), + "Should have MERGED_SPEC, got: {code}" + ); + // quote! generates "merged . merge" with spaces + assert!( + code.contains("merged . merge") || code.contains("merged.merge"), + "Should call merge on spec, got: {code}" + ); + } + + #[test] + fn test_generate_router_code_with_redoc_and_merge() { + let metadata = CollectedMetadata::new(); + let spec = r#"{"openapi":"3.1.0"}"#; + let merge_apps: Vec = vec![syn::parse_quote!(other::OtherApp)]; + + let result = generate_router_code( + &metadata, + None, + Some("/redoc"), + Some(quote::quote!(#spec)), + &merge_apps, + &[], + ); + let code = result.to_string(); + + // Should have merge code for redoc + assert!( + code.contains("OnceLock"), + "Should use OnceLock for merged redoc" + ); + assert!(code.contains("redoc"), "Should contain redoc"); + } + + #[test] + fn test_generate_router_code_with_both_docs_and_merge() { + let metadata = CollectedMetadata::new(); + let spec = r#"{"openapi":"3.1.0"}"#; + let merge_apps: Vec = vec![syn::parse_quote!(merged::App)]; + + let result = generate_router_code( + &metadata, + Some("/docs"), + Some("/redoc"), + Some(quote::quote!(#spec)), + &merge_apps, + &[], + ); + let code = result.to_string(); + + // Both docs should have merge code + // Count MERGED_SPEC occurrences - should appear in docs and redoc handlers + let merged_spec_count = code.matches("MERGED_SPEC").count(); + assert!( + merged_spec_count >= 2, + "Should have at least 2 MERGED_SPEC for docs and redoc, got: {merged_spec_count}" + ); + // __VESPERA_SPEC should appear exactly once (the const declaration) + let vespera_spec_count = code.matches("__VESPERA_SPEC").count(); + assert!( + vespera_spec_count >= 1, + "Should have __VESPERA_SPEC const, got: {vespera_spec_count}" + ); + // Both docs_url and redoc_url should be present + assert!( + code.contains("/docs") && code.contains("/redoc"), + "Should contain both /docs and /redoc" + ); + } + + #[test] + fn test_generate_router_code_with_multiple_merge_apps() { + let metadata = CollectedMetadata::new(); + let merge_apps: Vec = vec![ + syn::parse_quote!(first::App), + syn::parse_quote!(second::App), + ]; + + let result = generate_router_code(&metadata, None, None, None, &merge_apps, &[]); + let code = result.to_string(); + + // Should reference both apps + assert!( + code.contains("first") && code.contains("second"), + "Should reference both merge apps, got: {code}" + ); + } + + // ========== Tests for generate_router_code with cron jobs ========== + + #[test] + fn test_generate_router_code_with_merge_and_cron() { + let metadata = CollectedMetadata::new(); + let merge_apps: Vec = vec![syn::parse_quote!(third::ThirdApp)]; + let cron_jobs = vec![CronMetadata { + expression: "0 */5 * * * *".to_string(), + function_name: "cleanup".to_string(), + module_path: "tasks".to_string(), + file_path: "src/tasks.rs".to_string(), + }]; + + let result = generate_router_code(&metadata, None, None, None, &merge_apps, &cron_jobs); + let code = result.to_string(); + + assert!( + code.contains("VesperaRouter"), + "Should use VesperaRouter for merge, got: {code}" + ); + assert!( + code.contains("JobScheduler"), + "Should contain cron scheduler code, got: {code}" + ); + assert!( + code.contains("cleanup"), + "Should reference cron function, got: {code}" + ); + } + + #[test] + fn test_generate_router_code_with_cron_no_merge() { + let metadata = CollectedMetadata::new(); + let cron_jobs = vec![CronMetadata { + expression: "1/10 * * * * *".to_string(), + function_name: "heartbeat".to_string(), + module_path: "cron::health".to_string(), + file_path: "src/cron/health.rs".to_string(), + }]; + + let result = generate_router_code(&metadata, None, None, None, &[], &cron_jobs); + let code = result.to_string(); + + assert!( + !code.contains("VesperaRouter"), + "Should NOT use VesperaRouter without merge, got: {code}" + ); + assert!( + code.contains("JobScheduler"), + "Should contain cron scheduler code, got: {code}" + ); + assert!( + code.contains("heartbeat"), + "Should reference cron function, got: {code}" + ); + } +} diff --git a/crates/vespera_macro/src/router_codegen/input.rs b/crates/vespera_macro/src/router_codegen/input.rs new file mode 100644 index 00000000..49f39287 --- /dev/null +++ b/crates/vespera_macro/src/router_codegen/input.rs @@ -0,0 +1,783 @@ +use proc_macro2::Span; +use syn::{ + LitStr, bracketed, + parse::{Parse, ParseStream}, + punctuated::Punctuated, +}; +use vespera_core::openapi::Server; + +/// Server configuration for `OpenAPI` +#[derive(Clone)] +pub struct ServerConfig { + pub url: String, + pub description: Option, +} + +/// Input for the `vespera!` macro +pub struct AutoRouterInput { + pub dir: Option, + pub openapi: Option>, + pub title: Option, + pub version: Option, + pub docs_url: Option, + pub redoc_url: Option, + pub servers: Option>, + /// Apps to merge (e.g., [`third::ThirdApp`, `another::AnotherApp`]) + pub merge: Option>, +} + +impl Parse for AutoRouterInput { + #[allow(clippy::too_many_lines)] + fn parse(input: ParseStream) -> syn::Result { + let mut dir = None; + let mut openapi = None; + let mut title = None; + let mut version = None; + let mut docs_url = None; + let mut redoc_url = None; + let mut servers = None; + let mut merge = None; + + while !input.is_empty() { + let lookahead = input.lookahead1(); + + if lookahead.peek(syn::Ident) { + let ident: syn::Ident = input.parse()?; + let ident_str = ident.to_string(); + + match ident_str.as_str() { + "dir" => { + input.parse::()?; + dir = Some(input.parse()?); + } + "openapi" => { + openapi = Some(parse_openapi_values(input)?); + } + "docs_url" => { + input.parse::()?; + docs_url = Some(input.parse()?); + } + "redoc_url" => { + input.parse::()?; + redoc_url = Some(input.parse()?); + } + "title" => { + input.parse::()?; + title = Some(input.parse()?); + } + "version" => { + input.parse::()?; + version = Some(input.parse()?); + } + "servers" => { + servers = Some(parse_servers_values(input)?); + } + "merge" => { + merge = Some(parse_merge_values(input)?); + } + _ => { + return Err(syn::Error::new( + ident.span(), + format!( + "unknown field: `{ident_str}`. Expected `dir`, `openapi`, `title`, `version`, `docs_url`, `redoc_url`, `servers`, or `merge`" + ), + )); + } + } + } else if lookahead.peek(syn::LitStr) { + // If just a string, treat it as dir (for backward compatibility) + dir = Some(input.parse()?); + } else { + return Err(lookahead.error()); + } + + if input.peek(syn::Token![,]) { + input.parse::()?; + } else { + break; + } + } + + Ok(Self { + dir: dir.or_else(|| { + std::env::var("VESPERA_DIR") + .map(|f| LitStr::new(&f, Span::call_site())) + .ok() + }), + openapi: openapi.or_else(|| { + std::env::var("VESPERA_OPENAPI") + .map(|f| vec![LitStr::new(&f, Span::call_site())]) + .ok() + }), + title: title.or_else(|| { + std::env::var("VESPERA_TITLE") + .map(|f| LitStr::new(&f, Span::call_site())) + .ok() + }), + version: version + .or_else(|| { + std::env::var("VESPERA_VERSION") + .map(|f| LitStr::new(&f, Span::call_site())) + .ok() + }) + .or_else(|| { + std::env::var("CARGO_PKG_VERSION") + .map(|f| LitStr::new(&f, Span::call_site())) + .ok() + }), + docs_url: docs_url.or_else(|| { + std::env::var("VESPERA_DOCS_URL") + .map(|f| LitStr::new(&f, Span::call_site())) + .ok() + }), + redoc_url: redoc_url.or_else(|| { + std::env::var("VESPERA_REDOC_URL") + .map(|f| LitStr::new(&f, Span::call_site())) + .ok() + }), + servers: servers.or_else(|| { + std::env::var("VESPERA_SERVER_URL") + .ok() + .filter(|url| url.starts_with("http://") || url.starts_with("https://")) + .map(|url| { + vec![ServerConfig { + url, + description: std::env::var("VESPERA_SERVER_DESCRIPTION").ok(), + }] + }) + }), + merge, + }) + } +} + +/// Parse merge values: merge = [`path::to::App`, `another::App`] +fn parse_merge_values(input: ParseStream) -> syn::Result> { + input.parse::()?; + + let content; + let _ = bracketed!(content in input); + let paths: Punctuated = + content.parse_terminated(syn::Path::parse, syn::Token![,])?; + Ok(paths.into_iter().collect()) +} + +fn parse_openapi_values(input: ParseStream) -> syn::Result> { + input.parse::()?; + + if input.peek(syn::token::Bracket) { + let content; + let _ = bracketed!(content in input); + let entries: Punctuated = + content.parse_terminated(syn::parse::ParseBuffer::parse::, syn::Token![,])?; + Ok(entries.into_iter().collect()) + } else { + let single: LitStr = input.parse()?; + Ok(vec![single]) + } +} + +/// Validate that a URL starts with http:// or https:// +fn validate_server_url(url: &LitStr) -> syn::Result { + let url_value = url.value(); + if !url_value.starts_with("http://") && !url_value.starts_with("https://") { + return Err(syn::Error::new( + url.span(), + format!( + "invalid server URL: `{url_value}`. URL must start with `http://` or `https://`" + ), + )); + } + Ok(url_value) +} + +/// Parse server values in various formats: +/// - `servers = "url"` - single URL +/// - `servers = ["url1", "url2"]` - multiple URLs (strings only) +/// - `servers = [("url", "description")]` - tuple format with descriptions +/// - `servers = [{url = "...", description = "..."}]` - struct-like format +/// - `servers = {url = "...", description = "..."}` - single server struct-like format +fn parse_servers_values(input: ParseStream) -> syn::Result> { + use syn::token::{Brace, Paren}; + + input.parse::()?; + + if input.peek(syn::token::Bracket) { + // Array format: [...] + let content; + let _ = bracketed!(content in input); + + let mut servers = Vec::new(); + + while !content.is_empty() { + if content.peek(Paren) { + // Parse tuple: ("url", "description") + let tuple_content; + syn::parenthesized!(tuple_content in content); + let url: LitStr = tuple_content.parse()?; + let url_value = validate_server_url(&url)?; + let description = if tuple_content.peek(syn::Token![,]) { + tuple_content.parse::()?; + Some(tuple_content.parse::()?.value()) + } else { + None + }; + servers.push(ServerConfig { + url: url_value, + description, + }); + } else if content.peek(Brace) { + // Parse struct-like: {url = "...", description = "..."} + let server = parse_server_struct(&content)?; + servers.push(server); + } else { + // Parse simple string: "url" + let url: LitStr = content.parse()?; + let url_value = validate_server_url(&url)?; + servers.push(ServerConfig { + url: url_value, + description: None, + }); + } + + if content.peek(syn::Token![,]) { + content.parse::()?; + } else { + break; + } + } + + Ok(servers) + } else if input.peek(syn::token::Brace) { + // Single struct-like format: servers = {url = "...", description = "..."} + let server = parse_server_struct(input)?; + Ok(vec![server]) + } else { + // Single string: servers = "url" + let single: LitStr = input.parse()?; + let url_value = validate_server_url(&single)?; + Ok(vec![ServerConfig { + url: url_value, + description: None, + }]) + } +} + +/// Parse a single server in struct-like format: {url = "...", description = "..."} +fn parse_server_struct(input: ParseStream) -> syn::Result { + let content; + syn::braced!(content in input); + + let mut url: Option = None; + let mut description: Option = None; + + while !content.is_empty() { + let ident: syn::Ident = content.parse()?; + let ident_str = ident.to_string(); + + match ident_str.as_str() { + "url" => { + content.parse::()?; + let url_lit: LitStr = content.parse()?; + url = Some(validate_server_url(&url_lit)?); + } + "description" => { + content.parse::()?; + description = Some(content.parse::()?.value()); + } + _ => { + return Err(syn::Error::new( + ident.span(), + format!("unknown field: `{ident_str}`. Expected `url` or `description`"), + )); + } + } + + if content.peek(syn::Token![,]) { + content.parse::()?; + } else { + break; + } + } + + let url = url.ok_or_else(|| syn::Error::new(proc_macro2::Span::call_site(), "vespera! macro: server configuration missing required `url` field. Use format: `servers = { url = \"http://localhost:3000\" }` or `servers = { url = \"...\", description = \"...\" }`."))?; + + Ok(ServerConfig { url, description }) +} + +/// Processed vespera input with extracted values +pub struct ProcessedVesperaInput { + pub folder_name: String, + pub openapi_file_names: Vec, + pub title: Option, + pub version: Option, + pub docs_url: Option, + pub redoc_url: Option, + pub servers: Option>, + /// Apps to merge (`syn::Path` for code generation) + pub merge: Vec, +} + +/// Process `AutoRouterInput` into extracted values +pub fn process_vespera_input(input: AutoRouterInput) -> ProcessedVesperaInput { + ProcessedVesperaInput { + folder_name: input + .dir + .map_or_else(|| "routes".to_string(), |f| f.value()), + openapi_file_names: input + .openapi + .unwrap_or_default() + .into_iter() + .map(|f| f.value()) + .collect(), + title: input.title.map(|t| t.value()), + version: input.version.map(|v| v.value()), + docs_url: input.docs_url.map(|u| u.value()), + redoc_url: input.redoc_url.map(|u| u.value()), + servers: input.servers.map(|svrs| { + svrs.into_iter() + .map(|s| Server { + url: s.url, + description: s.description, + variables: None, + }) + .collect() + }), + merge: input.merge.unwrap_or_default(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_openapi_values_single() { + // Test that single string openapi value parses correctly via AutoRouterInput + let tokens = quote::quote!(openapi = "openapi.json"); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let openapi = input.openapi.unwrap(); + assert_eq!(openapi.len(), 1); + assert_eq!(openapi[0].value(), "openapi.json"); + } + + #[test] + fn test_parse_openapi_values_array() { + // Test that array openapi value parses correctly via AutoRouterInput + let tokens = quote::quote!(openapi = ["openapi.json", "api.json"]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let openapi = input.openapi.unwrap(); + assert_eq!(openapi.len(), 2); + assert_eq!(openapi[0].value(), "openapi.json"); + assert_eq!(openapi[1].value(), "api.json"); + } + + #[test] + fn test_validate_server_url_valid_http() { + let lit = LitStr::new("http://localhost:3000", Span::call_site()); + let result = validate_server_url(&lit); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "http://localhost:3000"); + } + + #[test] + fn test_validate_server_url_valid_https() { + let lit = LitStr::new("https://api.example.com", Span::call_site()); + let result = validate_server_url(&lit); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "https://api.example.com"); + } + + #[test] + fn test_validate_server_url_invalid() { + let lit = LitStr::new("ftp://example.com", Span::call_site()); + let result = validate_server_url(&lit); + assert!(result.is_err()); + } + + #[test] + fn test_validate_server_url_no_scheme() { + let lit = LitStr::new("example.com", Span::call_site()); + let result = validate_server_url(&lit); + assert!(result.is_err()); + } + + #[test] + fn test_auto_router_input_parse_dir_only() { + let tokens = quote::quote!(dir = "api"); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.dir.unwrap().value(), "api"); + assert!(input.openapi.is_none()); + } + + #[test] + fn test_auto_router_input_parse_string_as_dir() { + let tokens = quote::quote!("routes"); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.dir.unwrap().value(), "routes"); + } + + #[test] + fn test_auto_router_input_parse_openapi_single() { + let tokens = quote::quote!(openapi = "openapi.json"); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let openapi = input.openapi.unwrap(); + assert_eq!(openapi.len(), 1); + assert_eq!(openapi[0].value(), "openapi.json"); + } + + #[test] + fn test_auto_router_input_parse_openapi_array() { + let tokens = quote::quote!(openapi = ["a.json", "b.json"]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let openapi = input.openapi.unwrap(); + assert_eq!(openapi.len(), 2); + } + + #[test] + fn test_auto_router_input_parse_title_version() { + let tokens = quote::quote!(title = "My API", version = "2.0.0"); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.title.unwrap().value(), "My API"); + assert_eq!(input.version.unwrap().value(), "2.0.0"); + } + + #[test] + fn test_auto_router_input_parse_docs_redoc() { + let tokens = quote::quote!(docs_url = "/docs", redoc_url = "/redoc"); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.docs_url.unwrap().value(), "/docs"); + assert_eq!(input.redoc_url.unwrap().value(), "/redoc"); + } + + #[test] + fn test_auto_router_input_parse_servers_single() { + let tokens = quote::quote!(servers = "http://localhost:3000"); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let servers = input.servers.unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].url, "http://localhost:3000"); + assert!(servers[0].description.is_none()); + } + + #[test] + fn test_auto_router_input_parse_servers_array_strings() { + let tokens = quote::quote!(servers = ["http://localhost:3000", "https://api.example.com"]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let servers = input.servers.unwrap(); + assert_eq!(servers.len(), 2); + } + + #[test] + fn test_auto_router_input_parse_servers_tuple() { + let tokens = quote::quote!(servers = [("http://localhost:3000", "Development")]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let servers = input.servers.unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].url, "http://localhost:3000"); + assert_eq!(servers[0].description, Some("Development".to_string())); + } + + #[test] + fn test_auto_router_input_parse_servers_struct() { + let tokens = + quote::quote!(servers = [{ url = "http://localhost:3000", description = "Dev" }]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let servers = input.servers.unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].url, "http://localhost:3000"); + assert_eq!(servers[0].description, Some("Dev".to_string())); + } + + #[test] + fn test_auto_router_input_parse_servers_single_struct() { + let tokens = quote::quote!(servers = { url = "https://api.example.com" }); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let servers = input.servers.unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].url, "https://api.example.com"); + } + + #[test] + fn test_auto_router_input_parse_unknown_field() { + let tokens = quote::quote!(unknown_field = "value"); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err()); + } + + #[test] + fn test_auto_router_input_parse_all_fields() { + let tokens = quote::quote!( + dir = "api", + openapi = "openapi.json", + title = "Test API", + version = "1.0.0", + docs_url = "/docs", + redoc_url = "/redoc", + servers = "http://localhost:3000" + ); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + assert!(input.dir.is_some()); + assert!(input.openapi.is_some()); + assert!(input.title.is_some()); + assert!(input.version.is_some()); + assert!(input.docs_url.is_some()); + assert!(input.redoc_url.is_some()); + assert!(input.servers.is_some()); + } + + #[test] + fn test_parse_server_struct_url_only() { + // Test server struct parsing via AutoRouterInput + let tokens = quote::quote!(servers = { url = "http://localhost:3000" }); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let servers = input.servers.unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].url, "http://localhost:3000"); + assert!(servers[0].description.is_none()); + } + + #[test] + fn test_parse_server_struct_with_description() { + let tokens = + quote::quote!(servers = { url = "http://localhost:3000", description = "Local" }); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let servers = input.servers.unwrap(); + assert_eq!(servers[0].description, Some("Local".to_string())); + } + + #[test] + fn test_parse_server_struct_unknown_field() { + let tokens = quote::quote!(servers = { url = "http://localhost:3000", unknown = "test" }); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err()); + } + + #[test] + fn test_parse_server_struct_missing_url() { + let tokens = quote::quote!(servers = { description = "test" }); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err()); + } + + #[test] + fn test_parse_servers_tuple_url_only() { + let tokens = quote::quote!(servers = [("http://localhost:3000")]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let servers = input.servers.unwrap(); + assert_eq!(servers.len(), 1); + assert!(servers[0].description.is_none()); + } + + #[test] + fn test_parse_servers_invalid_url() { + let tokens = quote::quote!(servers = "invalid-url"); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err()); + } + + #[test] + fn test_auto_router_input_parse_invalid_token() { + // Test line 149: neither ident nor string literal triggers lookahead error + let tokens = quote::quote!(123); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err()); + } + + #[test] + fn test_auto_router_input_empty() { + // Test empty input - should use defaults/env vars + let tokens = quote::quote!(); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_ok()); + } + + #[test] + fn test_auto_router_input_multiple_commas() { + // Test input with trailing comma + let tokens = quote::quote!(dir = "api",); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_ok()); + } + + #[test] + fn test_auto_router_input_no_comma() { + // Test input without comma between fields (should stop at second field) + let tokens = quote::quote!(dir = "api" title = "Test"); + let result: syn::Result = syn::parse2(tokens); + // This should fail or only parse first field + assert!(result.is_err()); + } + + #[test] + fn test_process_vespera_input_defaults() { + let tokens = quote::quote!(); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let processed = process_vespera_input(input); + assert_eq!(processed.folder_name, "routes"); + assert!(processed.openapi_file_names.is_empty()); + assert!(processed.title.is_none()); + assert!(processed.docs_url.is_none()); + } + + #[test] + fn test_process_vespera_input_all_fields() { + let tokens = quote::quote!( + dir = "api", + openapi = ["openapi.json", "api.json"], + title = "My API", + version = "1.0.0", + docs_url = "/docs", + redoc_url = "/redoc", + servers = "http://localhost:3000" + ); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let processed = process_vespera_input(input); + assert_eq!(processed.folder_name, "api"); + assert_eq!( + processed.openapi_file_names, + vec!["openapi.json", "api.json"] + ); + assert_eq!(processed.title, Some("My API".to_string())); + assert_eq!(processed.version, Some("1.0.0".to_string())); + assert_eq!(processed.docs_url, Some("/docs".to_string())); + assert_eq!(processed.redoc_url, Some("/redoc".to_string())); + let servers = processed.servers.unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].url, "http://localhost:3000"); + } + + #[test] + fn test_process_vespera_input_servers_with_description() { + let tokens = quote::quote!( + servers = [{ url = "https://api.example.com", description = "Production" }] + ); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let processed = process_vespera_input(input); + let servers = processed.servers.unwrap(); + assert_eq!(servers[0].url, "https://api.example.com"); + assert_eq!(servers[0].description, Some("Production".to_string())); + } + + // ========== Tests for parse_merge_values ========== + + #[test] + fn test_parse_merge_values_single() { + let tokens = quote::quote!(merge = [some::path::App]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let merge = input.merge.unwrap(); + assert_eq!(merge.len(), 1); + // Check the path segments + let path = &merge[0]; + let segments: Vec<_> = path.segments.iter().map(|s| s.ident.to_string()).collect(); + assert_eq!(segments, vec!["some", "path", "App"]); + } + + #[test] + fn test_parse_merge_values_multiple() { + let tokens = quote::quote!(merge = [first::App, second::Other]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let merge = input.merge.unwrap(); + assert_eq!(merge.len(), 2); + } + + #[test] + fn test_parse_merge_values_empty() { + let tokens = quote::quote!(merge = []); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let merge = input.merge.unwrap(); + assert!(merge.is_empty()); + } + + #[test] + fn test_parse_merge_values_with_trailing_comma() { + let tokens = quote::quote!(merge = [app::MyApp,]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let merge = input.merge.unwrap(); + assert_eq!(merge.len(), 1); + } + + #[test] + #[serial_test::serial] + fn test_auto_router_input_server_env_var_fallback() { + // Test lines 181-183: VESPERA_SERVER_URL env var fallback + // `#[serial]` serializes this with every other env-mutating test so + // the process-global VESPERA_SERVER_* vars cannot race across the + // parallel test threads. + let test_url = "https://vespera-test-unique-12345.example.com"; + let test_desc = "Vespera Test Server 12345"; + + // Save current state + let old_server_url = std::env::var("VESPERA_SERVER_URL").ok(); + let old_server_desc = std::env::var("VESPERA_SERVER_DESCRIPTION").ok(); + + // SAFETY: Single-threaded test context + unsafe { + std::env::set_var("VESPERA_SERVER_URL", test_url); + std::env::set_var("VESPERA_SERVER_DESCRIPTION", test_desc); + } + + // Parse empty input - should pick up env vars + let tokens = quote::quote!(); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + + // Restore env vars immediately after parsing + unsafe { + if let Some(url) = old_server_url { + std::env::set_var("VESPERA_SERVER_URL", url); + } else { + std::env::remove_var("VESPERA_SERVER_URL"); + } + if let Some(desc) = old_server_desc { + std::env::set_var("VESPERA_SERVER_DESCRIPTION", desc); + } else { + std::env::remove_var("VESPERA_SERVER_DESCRIPTION"); + } + } + + // Check if servers was set - may not be if another test interfered + if let Some(servers) = input.servers { + // If we got servers, verify they match our test values + if servers.len() == 1 && servers[0].url == test_url { + assert_eq!(servers[0].description, Some(test_desc.to_string())); + } + // Otherwise another test's values were picked up, which is fine + } + // If servers is None, another test may have cleared the env var - acceptable + } + + #[test] + #[serial_test::serial] + fn test_auto_router_input_server_env_var_invalid_url_filtered() { + // Test that invalid URLs (not http/https) are filtered out by the .filter() call + // This exercises the filter branch, not lines 181-183 directly + let old_server_url = std::env::var("VESPERA_SERVER_URL").ok(); + + // SAFETY: Single-threaded test context + unsafe { + std::env::set_var("VESPERA_SERVER_URL", "ftp://invalid-url-test.com"); + } + + let tokens = quote::quote!(); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + + // Restore env var + unsafe { + if let Some(url) = old_server_url { + std::env::set_var("VESPERA_SERVER_URL", url); + } else { + std::env::remove_var("VESPERA_SERVER_URL"); + } + } + + // If servers is Some, it means another test set a valid URL - acceptable + // If servers is None, our invalid URL was correctly filtered + if let Some(servers) = &input.servers { + // Another test set a valid URL, check it's not our invalid one + assert!( + servers.is_empty() || servers[0].url != "ftp://invalid-url-test.com", + "Invalid ftp:// URL should have been filtered" + ); + } + } +} diff --git a/crates/vespera_macro/src/router_codegen/process.rs b/crates/vespera_macro/src/router_codegen/process.rs new file mode 100644 index 00000000..3f7193f9 --- /dev/null +++ b/crates/vespera_macro/src/router_codegen/process.rs @@ -0,0 +1,106 @@ +//! Normalisation of [`AutoRouterInput`] into a builder-friendly form. +//! +//! [`ProcessedVesperaInput`] is the value [`crate::vespera_impl`] consumes when +//! orchestrating the `vespera!` macro — defaults are filled in here so the +//! orchestrator can stay agnostic about parse details. + +use vespera_core::openapi::Server; + +use super::input::AutoRouterInput; + +/// Processed vespera input with extracted values +pub struct ProcessedVesperaInput { + pub folder_name: String, + pub openapi_file_names: Vec, + pub title: Option, + pub version: Option, + pub docs_url: Option, + pub redoc_url: Option, + pub servers: Option>, + /// Apps to merge (`syn::Path` for code generation) + pub merge: Vec, +} + +/// Process `AutoRouterInput` into extracted values +pub fn process_vespera_input(input: AutoRouterInput) -> ProcessedVesperaInput { + ProcessedVesperaInput { + folder_name: input + .dir + .map_or_else(|| "routes".to_string(), |f| f.value()), + openapi_file_names: input + .openapi + .unwrap_or_default() + .into_iter() + .map(|f| f.value()) + .collect(), + title: input.title.map(|t| t.value()), + version: input.version.map(|v| v.value()), + docs_url: input.docs_url.map(|u| u.value()), + redoc_url: input.redoc_url.map(|u| u.value()), + servers: input.servers.map(|svrs| { + svrs.into_iter() + .map(|s| Server { + url: s.url, + description: s.description, + variables: None, + }) + .collect() + }), + merge: input.merge.unwrap_or_default(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_process_vespera_input_defaults() { + let tokens = quote::quote!(); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let processed = process_vespera_input(input); + assert_eq!(processed.folder_name, "routes"); + assert!(processed.openapi_file_names.is_empty()); + assert!(processed.title.is_none()); + assert!(processed.docs_url.is_none()); + } + + #[test] + fn test_process_vespera_input_all_fields() { + let tokens = quote::quote!( + dir = "api", + openapi = ["openapi.json", "api.json"], + title = "My API", + version = "1.0.0", + docs_url = "/docs", + redoc_url = "/redoc", + servers = "http://localhost:3000" + ); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let processed = process_vespera_input(input); + assert_eq!(processed.folder_name, "api"); + assert_eq!( + processed.openapi_file_names, + vec!["openapi.json", "api.json"] + ); + assert_eq!(processed.title, Some("My API".to_string())); + assert_eq!(processed.version, Some("1.0.0".to_string())); + assert_eq!(processed.docs_url, Some("/docs".to_string())); + assert_eq!(processed.redoc_url, Some("/redoc".to_string())); + let servers = processed.servers.unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].url, "http://localhost:3000"); + } + + #[test] + fn test_process_vespera_input_servers_with_description() { + let tokens = quote::quote!( + servers = [{ url = "https://api.example.com", description = "Production" }] + ); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let processed = process_vespera_input(input); + let servers = processed.servers.unwrap(); + assert_eq!(servers[0].url, "https://api.example.com"); + assert_eq!(servers[0].description, Some("Production".to_string())); + } +} diff --git a/crates/vespera_macro/src/schema_macro/circular.rs b/crates/vespera_macro/src/schema_macro/circular.rs index 70cbfe9f..e987772b 100644 --- a/crates/vespera_macro/src/schema_macro/circular.rs +++ b/crates/vespera_macro/src/schema_macro/circular.rs @@ -292,114 +292,82 @@ pub fn generate_inline_type_construction( #[cfg(test)] mod tests { + use quote::quote; use rstest::rstest; use super::*; + fn ident(name: &str) -> syn::Ident { + syn::Ident::new(name, proc_macro2::Span::call_site()) + } + + fn fields(src: &str) -> syn::FieldsNamed { + syn::parse_str(src).unwrap() + } + + fn required(def: &str, field: &str) -> bool { + analyze_circular_refs(&[], def) + .circular_field_required + .get(field) + .copied() + .unwrap_or(false) + } + #[rstest] - #[case( - &["crate", "models", "memo"], - r"pub struct UserSchema { - pub id: i32, - pub memos: HasMany, - }", - vec![] // HasMany is not considered circular - )] - #[case( - &["crate", "models", "user"], - r"pub struct MemoSchema { - pub id: i32, - pub user: BelongsTo, - }", - vec!["user".to_string()] - )] - #[case( - &["crate", "models", "user"], - r"pub struct MemoSchema { - pub id: i32, - pub user: HasOne, - }", - vec!["user".to_string()] - )] - #[case( - &["crate", "models", "user"], - r"pub struct MemoSchema { - pub id: i32, - pub user: Box, - }", - vec!["user".to_string()] - )] - #[case( - &["crate", "models", "memo"], - r"pub struct UserSchema { - pub id: i32, - pub name: String, - }", - vec![] // No circular fields - )] + #[case(&["crate", "models", "memo"], r"pub struct UserSchema { pub id: i32, pub memos: HasMany, }", vec![])] + #[case(&["crate", "models", "user"], r"pub struct MemoSchema { pub id: i32, pub user: BelongsTo, }", vec!["user".to_string()])] + #[case(&["crate", "models", "user"], r"pub struct MemoSchema { pub id: i32, pub user: HasOne, }", vec!["user".to_string()])] + #[case(&["crate", "models", "user"], r"pub struct MemoSchema { pub id: i32, pub user: Box, }", vec!["user".to_string()])] + #[case(&["crate", "models", "memo"], r"pub struct UserSchema { pub id: i32, pub name: String, }", vec![])] fn test_detect_circular_fields( #[case] source_module_path: &[&str], #[case] related_schema_def: &str, #[case] expected: Vec, ) { - let module_path: Vec = source_module_path - .iter() - .map(std::string::ToString::to_string) - .collect(); - let result = analyze_circular_refs(&module_path, related_schema_def).circular_fields; - assert_eq!(result, expected); + let module_path: Vec = source_module_path.iter().map(ToString::to_string).collect(); + assert_eq!( + analyze_circular_refs(&module_path, related_schema_def).circular_fields, + expected + ); } #[test] fn test_detect_circular_fields_invalid_struct() { - let result = - analyze_circular_refs(&["crate".to_string()], "not valid rust").circular_fields; - assert!(result.is_empty()); + assert!( + analyze_circular_refs(&["crate".to_string()], "not valid rust") + .circular_fields + .is_empty() + ); } #[test] fn test_detect_circular_fields_unnamed_fields() { - let result = analyze_circular_refs( - &[ - "crate".to_string(), - "models".to_string(), - "test".to_string(), - ], - "pub struct TupleStruct(i32, String);", - ) - .circular_fields; - assert!(result.is_empty()); + let path = vec![ + "crate".to_string(), + "models".to_string(), + "test".to_string(), + ]; + assert!( + analyze_circular_refs(&path, "pub struct TupleStruct(i32, String);") + .circular_fields + .is_empty() + ); } #[rstest] #[case( - r"pub struct Model { - pub id: i32, - pub user: BelongsTo, - }", + r"pub struct Model { pub id: i32, pub user: BelongsTo, }", true )] #[case( - r"pub struct Model { - pub id: i32, - pub user: HasOne, - }", + r"pub struct Model { pub id: i32, pub user: HasOne, }", true )] + #[case(r"pub struct Model { pub id: i32, pub name: String, }", false)] #[case( - r"pub struct Model { - pub id: i32, - pub name: String, - }", + r"pub struct Model { pub id: i32, pub items: HasMany, }", false )] - #[case( - r"pub struct Model { - pub id: i32, - pub items: HasMany, - }", - false // HasMany alone doesn't count as FK relation - )] fn test_has_fk_relations(#[case] model_def: &str, #[case] expected: bool) { assert_eq!( analyze_circular_refs(&[], model_def).has_fk_relations, @@ -421,106 +389,104 @@ mod tests { #[test] fn test_is_circular_relation_required_invalid_struct() { - assert!( - !analyze_circular_refs(&[], "not valid rust") - .circular_field_required - .get("user") - .copied() - .unwrap_or(false) - ); + assert!(!required("not valid rust", "user")); } #[test] fn test_is_circular_relation_required_unnamed_fields() { - assert!( - !analyze_circular_refs(&[], "pub struct TupleStruct(i32, String);") - .circular_field_required - .get("user") - .copied() - .unwrap_or(false) - ); + assert!(!required("pub struct TupleStruct(i32, String);", "user")); } #[test] fn test_is_circular_relation_required_field_not_found() { - let model_def = r"pub struct Model { - pub id: i32, - pub name: String, - }"; - assert!( - !analyze_circular_refs(&[], model_def) - .circular_field_required - .get("nonexistent") - .copied() - .unwrap_or(false) - ); + assert!(!required( + "pub struct Model { pub id: i32, pub name: String, }", + "nonexistent" + )); } #[test] fn test_generate_default_for_relation_field_has_many() { let ty: syn::Type = syn::parse_str("HasMany").unwrap(); - let field_ident = syn::Ident::new("users", proc_macro2::Span::call_site()); - let all_fields: syn::FieldsNamed = syn::parse_str("{ pub id: i32 }").unwrap(); - let tokens = generate_default_for_relation_field(&ty, &field_ident, &[], &all_fields); - let output = tokens.to_string(); - assert!(output.contains("users : vec ! []")); + assert!( + generate_default_for_relation_field( + &ty, + &ident("users"), + &[], + &fields("{ pub id: i32 }") + ) + .to_string() + .contains("users : vec ! []") + ); } #[test] fn test_generate_default_for_relation_field_has_one_optional() { let ty: syn::Type = syn::parse_str("HasOne").unwrap(); - let field_ident = syn::Ident::new("user", proc_macro2::Span::call_site()); - let all_fields: syn::FieldsNamed = syn::parse_str("{ pub user_id: Option }").unwrap(); - let tokens = generate_default_for_relation_field(&ty, &field_ident, &[], &all_fields); - let output = tokens.to_string(); - assert!(output.contains("user : None")); + assert!( + generate_default_for_relation_field( + &ty, + &ident("user"), + &[], + &fields("{ pub user_id: Option }") + ) + .to_string() + .contains("user : None") + ); } #[test] fn test_generate_default_for_relation_field_unknown_type() { let ty: syn::Type = syn::parse_str("SomeUnknownType").unwrap(); - let field_ident = syn::Ident::new("field", proc_macro2::Span::call_site()); - let all_fields: syn::FieldsNamed = syn::parse_str("{ pub id: i32 }").unwrap(); - let tokens = generate_default_for_relation_field(&ty, &field_ident, &[], &all_fields); - let output = tokens.to_string(); - assert!(output.contains("Default :: default ()")); + assert!( + generate_default_for_relation_field( + &ty, + &ident("field"), + &[], + &fields("{ pub id: i32 }") + ) + .to_string() + .contains("Default :: default ()") + ); } #[test] fn test_generate_inline_struct_construction_invalid_struct() { - let schema_path = quote! { user::Schema }; - let tokens = - generate_inline_struct_construction(&schema_path, "not valid rust", &[], "model"); - let output = tokens.to_string(); - assert!(output.contains("From")); + assert!( + generate_inline_struct_construction( + "e! { user::Schema }, + "not valid rust", + &[], + "model" + ) + .to_string() + .contains("From") + ); } #[test] fn test_generate_inline_struct_construction_tuple_struct() { - let schema_path = quote! { user::Schema }; - let tokens = generate_inline_struct_construction( - &schema_path, - "pub struct TupleStruct(i32, String);", - &[], - "model", + assert!( + generate_inline_struct_construction( + "e! { user::Schema }, + "pub struct TupleStruct(i32, String);", + &[], + "model" + ) + .to_string() + .contains("From") ); - let output = tokens.to_string(); - assert!(output.contains("From")); } #[test] fn test_generate_inline_struct_construction_with_fields() { - let schema_path = quote! { user::Schema }; - let tokens = generate_inline_struct_construction( - &schema_path, - r"pub struct UserSchema { - pub id: i32, - pub name: String, - }", + let output = generate_inline_struct_construction( + "e! { user::Schema }, + r"pub struct UserSchema { pub id: i32, pub name: String, }", &[], "r", - ); - let output = tokens.to_string(); + ) + .to_string(); assert!(output.contains("user :: Schema")); assert!(output.contains("id : r . id")); assert!(output.contains("name : r . name")); @@ -528,17 +494,13 @@ mod tests { #[test] fn test_generate_inline_struct_construction_with_circular_field() { - let schema_path = quote! { user::Schema }; - let tokens = generate_inline_struct_construction( - &schema_path, - r"pub struct UserSchema { - pub id: i32, - pub memos: HasMany, - }", + let output = generate_inline_struct_construction( + "e! { user::Schema }, + r"pub struct UserSchema { pub id: i32, pub memos: HasMany, }", &["memos".to_string()], "r", - ); - let output = tokens.to_string(); + ) + .to_string(); assert!(output.contains("user :: Schema")); assert!(output.contains("id : r . id")); assert!(output.contains("memos : vec ! []")); @@ -546,62 +508,54 @@ mod tests { #[test] fn test_generate_inline_struct_construction_skip_serde_skip_fields() { - let schema_path = quote! { user::Schema }; - let tokens = generate_inline_struct_construction( - &schema_path, - r"pub struct UserSchema { - pub id: i32, - #[serde(skip)] - pub internal: String, - }", + let output = generate_inline_struct_construction( + "e! { user::Schema }, + r"pub struct UserSchema { pub id: i32, #[serde(skip)] pub internal: String, }", &[], "r", - ); - let output = tokens.to_string(); + ) + .to_string(); assert!(output.contains("id : r . id")); assert!(!output.contains("internal : r . internal")); } #[test] fn test_generate_inline_type_construction_invalid_struct() { - let inline_type_name = syn::Ident::new("TestInline", proc_macro2::Span::call_site()); - let tokens = generate_inline_type_construction( - &inline_type_name, - &["id".to_string()], - "not valid rust", - "model", + assert!( + generate_inline_type_construction( + &ident("TestInline"), + &["id".to_string()], + "not valid rust", + "model" + ) + .to_string() + .contains("Default :: default ()") ); - let output = tokens.to_string(); - assert!(output.contains("Default :: default ()")); } #[test] fn test_generate_inline_type_construction_tuple_struct() { - let inline_type_name = syn::Ident::new("TestInline", proc_macro2::Span::call_site()); - let tokens = generate_inline_type_construction( - &inline_type_name, - &["id".to_string()], - "pub struct TupleStruct(i32, String);", - "model", + assert!( + generate_inline_type_construction( + &ident("TestInline"), + &["id".to_string()], + "pub struct TupleStruct(i32, String);", + "model" + ) + .to_string() + .contains("Default :: default ()") ); - let output = tokens.to_string(); - assert!(output.contains("Default :: default ()")); } #[test] fn test_generate_inline_type_construction_with_fields() { - let inline_type_name = syn::Ident::new("UserInline", proc_macro2::Span::call_site()); - let tokens = generate_inline_type_construction( - &inline_type_name, + let output = generate_inline_type_construction( + &ident("UserInline"), &["id".to_string(), "name".to_string()], - r"pub struct Model { - pub id: i32, - pub name: String, - pub email: String, - }", + r"pub struct Model { pub id: i32, pub name: String, pub email: String, }", "r", - ); - let output = tokens.to_string(); + ) + .to_string(); assert!(output.contains("UserInline")); assert!(output.contains("id : r . id")); assert!(output.contains("name : r . name")); @@ -610,266 +564,192 @@ mod tests { #[test] fn test_generate_inline_type_construction_skips_relations() { - let inline_type_name = syn::Ident::new("UserInline", proc_macro2::Span::call_site()); - let tokens = generate_inline_type_construction( - &inline_type_name, + let output = generate_inline_type_construction( + &ident("UserInline"), &["id".to_string(), "memos".to_string()], - r"pub struct Model { - pub id: i32, - pub memos: HasMany, - }", + r"pub struct Model { pub id: i32, pub memos: HasMany, }", "r", - ); - let output = tokens.to_string(); + ) + .to_string(); assert!(output.contains("id : r . id")); assert!(!output.contains("memos : r . memos")); } - // Additional coverage tests for circular_field_required via analyze_circular_refs - #[test] fn test_circular_field_required_has_one_with_required_fk() { - // Model has HasOne relation with a required (non-Option) FK field - let model_def = r#"pub struct Model { - pub id: i32, - pub user_id: i32, - #[sea_orm(belongs_to = "super::user::Entity", from = "Column::UserId", to = "super::user::Column::Id")] - pub user: HasOne, - }"#; - // The FK field 'user_id' is i32 (required), so circular relation IS required - let result = analyze_circular_refs(&[], model_def) - .circular_field_required - .get("user") - .copied() - .unwrap_or(false); - // Without proper BelongsTo attribute parsing, this returns false - // because extract_belongs_to_from_field won't find the FK - assert!(!result); + assert!(!required( + r#"pub struct Model { pub id: i32, pub user_id: i32, #[sea_orm(belongs_to = "super::user::Entity", from = "Column::UserId", to = "super::user::Column::Id")] pub user: HasOne, }"#, + "user" + )); } #[test] fn test_circular_field_required_belongs_to_with_optional_fk() { - // Model has BelongsTo relation with optional FK field - let model_def = r#"pub struct Model { - pub id: i32, - pub user_id: Option, - #[sea_orm(belongs_to = "super::user::Entity", from = "Column::UserId", to = "super::user::Column::Id")] - pub user: BelongsTo, - }"#; - // FK field is Option, so circular relation is NOT required - let result = analyze_circular_refs(&[], model_def) - .circular_field_required - .get("user") - .copied() - .unwrap_or(false); - assert!(!result); + assert!(!required( + r#"pub struct Model { pub id: i32, pub user_id: Option, #[sea_orm(belongs_to = "super::user::Entity", from = "Column::UserId", to = "super::user::Column::Id")] pub user: BelongsTo, }"#, + "user" + )); } #[test] fn test_circular_field_required_non_relation_field() { - // Field exists but is not a relation type - let model_def = r"pub struct Model { - pub id: i32, - pub name: String, - }"; - let result = analyze_circular_refs(&[], model_def) - .circular_field_required - .get("name") - .copied() - .unwrap_or(false); - assert!(!result); + assert!(!required( + r"pub struct Model { pub id: i32, pub name: String, }", + "name" + )); } #[test] fn test_circular_field_required_field_without_ident() { - // Struct with fields that have no ident (tuple-like, but in braces - edge case) - let model_def = r"pub struct Model { - pub id: i32, - }"; - // Looking for a field that doesn't match - let result = analyze_circular_refs(&[], model_def) - .circular_field_required - .get("nonexistent_field") - .copied() - .unwrap_or(false); - assert!(!result); + assert!(!required( + r"pub struct Model { pub id: i32, }", + "nonexistent_field" + )); } - // Additional coverage tests for generate_default_for_relation_field - #[test] fn test_generate_default_for_relation_field_belongs_to_optional() { let ty: syn::Type = syn::parse_str("BelongsTo").unwrap(); - let field_ident = syn::Ident::new("user", proc_macro2::Span::call_site()); - // FK field is optional - let all_fields: syn::FieldsNamed = syn::parse_str("{ pub user_id: Option }").unwrap(); - let tokens = generate_default_for_relation_field(&ty, &field_ident, &[], &all_fields); - let output = tokens.to_string(); - // Should produce None for optional - assert!(output.contains("user : None")); + assert!( + generate_default_for_relation_field( + &ty, + &ident("user"), + &[], + &fields("{ pub user_id: Option }") + ) + .to_string() + .contains("user : None") + ); } #[test] fn test_generate_default_for_relation_field_belongs_to_required() { let ty: syn::Type = syn::parse_str("BelongsTo").unwrap(); - let field_ident = syn::Ident::new("user", proc_macro2::Span::call_site()); - // FK field is required (not Option) - let all_fields: syn::FieldsNamed = syn::parse_str("{ pub user_id: i32 }").unwrap(); - // Without FK attribute, it defaults to optional behavior - let tokens = generate_default_for_relation_field(&ty, &field_ident, &[], &all_fields); - let output = tokens.to_string(); - // Without belongs_to attribute, defaults to None - assert!(output.contains("user : None")); + assert!( + generate_default_for_relation_field( + &ty, + &ident("user"), + &[], + &fields("{ pub user_id: i32 }") + ) + .to_string() + .contains("user : None") + ); } #[test] fn test_generate_default_for_relation_field_has_one_no_fk_found() { let ty: syn::Type = syn::parse_str("HasOne").unwrap(); - let field_ident = syn::Ident::new("user", proc_macro2::Span::call_site()); - // No FK field in all_fields - let all_fields: syn::FieldsNamed = syn::parse_str("{ pub id: i32 }").unwrap(); - let tokens = generate_default_for_relation_field(&ty, &field_ident, &[], &all_fields); - let output = tokens.to_string(); - // Without FK field found, defaults to None (optional behavior) - assert!(output.contains("user : None")); + assert!( + generate_default_for_relation_field( + &ty, + &ident("user"), + &[], + &fields("{ pub id: i32 }") + ) + .to_string() + .contains("user : None") + ); } - // Additional coverage tests for circular_fields via analyze_circular_refs - #[test] fn test_circular_fields_empty_module_path() { - // Edge case: empty module path - let result = - analyze_circular_refs(&[], "pub struct Schema { pub id: i32 }").circular_fields; - assert!(result.is_empty()); + assert!( + analyze_circular_refs(&[], "pub struct Schema { pub id: i32 }") + .circular_fields + .is_empty() + ); } #[test] fn test_circular_fields_option_box_pattern() { - // Test Option> pattern detection - let result = analyze_circular_refs( - &[ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ], - r"pub struct UserSchema { - pub id: i32, - pub memo: Option>, - }", - ) - .circular_fields; - assert_eq!(result, vec!["memo".to_string()]); + let path = vec![ + "crate".to_string(), + "models".to_string(), + "memo".to_string(), + ]; + assert_eq!( + analyze_circular_refs( + &path, + r"pub struct UserSchema { pub id: i32, pub memo: Option>, }" + ) + .circular_fields, + vec!["memo".to_string()] + ); } #[test] fn test_circular_fields_schema_suffix_pattern() { - // Test MemoSchema suffix pattern detection - let result = analyze_circular_refs( - &[ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ], - r"pub struct UserSchema { - pub id: i32, - pub memo: Box, - }", - ) - .circular_fields; - assert_eq!(result, vec!["memo".to_string()]); + let path = vec![ + "crate".to_string(), + "models".to_string(), + "memo".to_string(), + ]; + assert_eq!( + analyze_circular_refs( + &path, + r"pub struct UserSchema { pub id: i32, pub memo: Box, }" + ) + .circular_fields, + vec!["memo".to_string()] + ); } #[test] fn test_circular_fields_field_without_ident() { - // Fields without identifiers (parsing edge case) - let result = analyze_circular_refs( - &["crate".to_string(), "test".to_string()], - r"pub struct Schema { - pub id: i32, - }", - ) - .circular_fields; - assert!(result.is_empty()); + let path = vec!["crate".to_string(), "test".to_string()]; + assert!( + analyze_circular_refs(&path, r"pub struct Schema { pub id: i32, }") + .circular_fields + .is_empty() + ); } - // Additional coverage for generate_inline_struct_construction - #[test] fn test_generate_inline_struct_construction_with_belongs_to_relation() { - let schema_path = quote! { memo::Schema }; - let tokens = generate_inline_struct_construction( - &schema_path, - r"pub struct MemoSchema { - pub id: i32, - pub user_id: i32, - pub user: BelongsTo, - }", - &[], - "r", - ); - let output = tokens.to_string(); + let output = generate_inline_struct_construction("e! { memo::Schema }, r"pub struct MemoSchema { pub id: i32, pub user_id: i32, pub user: BelongsTo, }", &[], "r").to_string(); assert!(output.contains("memo :: Schema")); assert!(output.contains("id : r . id")); assert!(output.contains("user_id : r . user_id")); - // BelongsTo should get default value assert!(output.contains("user : None")); } #[test] fn test_generate_inline_struct_construction_with_has_one_relation() { - let schema_path = quote! { user::Schema }; - let tokens = generate_inline_struct_construction( - &schema_path, - r"pub struct UserSchema { - pub id: i32, - pub profile: HasOne, - }", + let output = generate_inline_struct_construction( + "e! { user::Schema }, + r"pub struct UserSchema { pub id: i32, pub profile: HasOne, }", &[], "r", - ); - let output = tokens.to_string(); + ) + .to_string(); assert!(output.contains("user :: Schema")); assert!(output.contains("id : r . id")); - // HasOne should get default value assert!(output.contains("profile : None")); } - // Additional coverage for generate_inline_type_construction - #[test] fn test_generate_inline_type_construction_skips_serde_skip() { - let inline_type_name = syn::Ident::new("TestInline", proc_macro2::Span::call_site()); - let tokens = generate_inline_type_construction( - &inline_type_name, + let output = generate_inline_type_construction( + &ident("TestInline"), &["id".to_string(), "internal".to_string()], - r"pub struct Model { - pub id: i32, - #[serde(skip)] - pub internal: String, - }", + r"pub struct Model { pub id: i32, #[serde(skip)] pub internal: String, }", "r", - ); - let output = tokens.to_string(); + ) + .to_string(); assert!(output.contains("id : r . id")); - // serde(skip) field should be excluded assert!(!output.contains("internal : r . internal")); } #[test] fn test_generate_inline_type_construction_empty_included_fields() { - let inline_type_name = syn::Ident::new("EmptyInline", proc_macro2::Span::call_site()); - let tokens = generate_inline_type_construction( - &inline_type_name, - &[], // No fields included - r"pub struct Model { - pub id: i32, - pub name: String, - }", + let output = generate_inline_type_construction( + &ident("EmptyInline"), + &[], + r"pub struct Model { pub id: i32, pub name: String, }", "r", - ); - let output = tokens.to_string(); - // Should produce empty struct construction + ) + .to_string(); assert!(output.contains("EmptyInline")); assert!(!output.contains("id : r . id")); assert!(!output.contains("name : r . name")); @@ -877,110 +757,61 @@ mod tests { #[test] fn test_generate_inline_type_construction_field_not_in_included() { - let inline_type_name = syn::Ident::new("PartialInline", proc_macro2::Span::call_site()); - let tokens = generate_inline_type_construction( - &inline_type_name, - &["id".to_string()], // Only id is included - r"pub struct Model { - pub id: i32, - pub name: String, - pub email: String, - }", + let output = generate_inline_type_construction( + &ident("PartialInline"), + &["id".to_string()], + r"pub struct Model { pub id: i32, pub name: String, pub email: String, }", "r", - ); - let output = tokens.to_string(); + ) + .to_string(); assert!(output.contains("id : r . id")); - // name and email should not be included assert!(!output.contains("name : r . name")); assert!(!output.contains("email : r . email")); } - // Tests for FK field lookup and required relation handling - #[test] fn test_circular_field_required_belongs_to_with_from_attr_required_fk() { - // Model has BelongsTo with sea_orm(from = "user_id") attribute and required FK - let model_def = r#"pub struct Model { - pub id: i32, - pub user_id: i32, - #[sea_orm(from = "user_id")] - pub user: BelongsTo, - }"#; - // FK field 'user_id' is i32 (required), so should return true - let result = analyze_circular_refs(&[], model_def) - .circular_field_required - .get("user") - .copied() - .unwrap_or(false); - assert!(result); + assert!(required( + r#"pub struct Model { pub id: i32, pub user_id: i32, #[sea_orm(from = "user_id")] pub user: BelongsTo, }"#, + "user" + )); } #[test] fn test_circular_field_required_belongs_to_with_from_attr_optional_fk() { - // Model has BelongsTo with sea_orm(from = "user_id") attribute and optional FK - let model_def = r#"pub struct Model { - pub id: i32, - pub user_id: Option, - #[sea_orm(from = "user_id")] - pub user: BelongsTo, - }"#; - // FK field 'user_id' is Option, so should return false - let result = analyze_circular_refs(&[], model_def) - .circular_field_required - .get("user") - .copied() - .unwrap_or(false); - assert!(!result); + assert!(!required( + r#"pub struct Model { pub id: i32, pub user_id: Option, #[sea_orm(from = "user_id")] pub user: BelongsTo, }"#, + "user" + )); } #[test] fn test_circular_field_required_has_one_with_from_attr_required_fk() { - // Model has HasOne with sea_orm(from = "profile_id") attribute and required FK - let model_def = r#"pub struct Model { - pub id: i32, - pub profile_id: i64, - #[sea_orm(from = "profile_id")] - pub profile: HasOne, - }"#; - // FK field 'profile_id' is i64 (required), so should return true - let result = analyze_circular_refs(&[], model_def) - .circular_field_required - .get("profile") - .copied() - .unwrap_or(false); - assert!(result); + assert!(required( + r#"pub struct Model { pub id: i32, pub profile_id: i64, #[sea_orm(from = "profile_id")] pub profile: HasOne, }"#, + "profile" + )); } #[test] fn test_circular_field_required_from_attr_fk_field_not_found() { - // Model has from attribute but FK field doesn't exist - let model_def = r#"pub struct Model { - pub id: i32, - #[sea_orm(from = "nonexistent_field")] - pub user: BelongsTo, - }"#; - // FK field doesn't exist, so should return false - let result = analyze_circular_refs(&[], model_def) - .circular_field_required - .get("user") - .copied() - .unwrap_or(false); - assert!(!result); + assert!(!required( + r#"pub struct Model { pub id: i32, #[sea_orm(from = "nonexistent_field")] pub user: BelongsTo, }"#, + "user" + )); } - // Tests for generate_default_for_relation_field with required FK - #[test] fn test_generate_default_for_relation_field_belongs_to_with_from_attr_required() { let ty: syn::Type = syn::parse_str("BelongsTo").unwrap(); - let field_ident = syn::Ident::new("user", proc_macro2::Span::call_site()); - // FK field is required (not Option) - let all_fields: syn::FieldsNamed = syn::parse_str("{ pub user_id: i32 }").unwrap(); - // Create proper sea_orm attribute with from let attr: syn::Attribute = syn::parse_quote!(#[sea_orm(from = "user_id")]); - let tokens = generate_default_for_relation_field(&ty, &field_ident, &[attr], &all_fields); - let output = tokens.to_string(); - // Should produce Box::new(__parent_stub__.clone()) for required FK + let output = generate_default_for_relation_field( + &ty, + &ident("user"), + &[attr], + &fields("{ pub user_id: i32 }"), + ) + .to_string(); assert!(output.contains("__parent_stub__")); assert!(output.contains("Box :: new")); } @@ -988,14 +819,14 @@ mod tests { #[test] fn test_generate_default_for_relation_field_has_one_with_from_attr_required() { let ty: syn::Type = syn::parse_str("HasOne").unwrap(); - let field_ident = syn::Ident::new("profile", proc_macro2::Span::call_site()); - // FK field is required (not Option) - let all_fields: syn::FieldsNamed = syn::parse_str("{ pub profile_id: i64 }").unwrap(); - // Create proper sea_orm attribute with from let attr: syn::Attribute = syn::parse_quote!(#[sea_orm(from = "profile_id")]); - let tokens = generate_default_for_relation_field(&ty, &field_ident, &[attr], &all_fields); - let output = tokens.to_string(); - // Should produce Box::new(__parent_stub__.clone()) for required FK + let output = generate_default_for_relation_field( + &ty, + &ident("profile"), + &[attr], + &fields("{ pub profile_id: i64 }"), + ) + .to_string(); assert!(output.contains("__parent_stub__")); assert!(output.contains("Box :: new")); } @@ -1003,15 +834,14 @@ mod tests { #[test] fn test_generate_default_for_relation_field_has_one_with_from_attr_optional() { let ty: syn::Type = syn::parse_str("HasOne").unwrap(); - let field_ident = syn::Ident::new("profile", proc_macro2::Span::call_site()); - // FK field is optional - let all_fields: syn::FieldsNamed = - syn::parse_str("{ pub profile_id: Option }").unwrap(); - // Create proper sea_orm attribute with from let attr: syn::Attribute = syn::parse_quote!(#[sea_orm(from = "profile_id")]); - let tokens = generate_default_for_relation_field(&ty, &field_ident, &[attr], &all_fields); - let output = tokens.to_string(); - // Should produce None for optional FK + let output = generate_default_for_relation_field( + &ty, + &ident("profile"), + &[attr], + &fields("{ pub profile_id: Option }"), + ) + .to_string(); assert!(output.contains("profile : None")); } } diff --git a/crates/vespera_macro/src/schema_macro/defaults.rs b/crates/vespera_macro/src/schema_macro/defaults.rs new file mode 100644 index 00000000..044649a9 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/defaults.rs @@ -0,0 +1,849 @@ +//! SeaORM default-value attribute generation. +//! +//! Translates `#[sea_orm(default_value = ...)]` / `#[sea_orm(primary_key)]` +//! on source fields into `#[serde(default = "...")]` + `#[schema(default = "...")]` +//! attributes (plus companion default functions) on the generated struct. + +use proc_macro2::TokenStream; +use quote::quote; + +use super::seaorm::{ + extract_sea_orm_default_value, has_sea_orm_primary_key, is_sql_function_default, +}; +use super::type_utils; +use crate::parser::extract_default; + +/// Generate `#[serde(default = "...")]` and `#[schema(default = "...")]` attributes +/// from `#[sea_orm(default_value = ...)]` or `#[sea_orm(primary_key)]` on source fields. +/// +/// Returns `(serde_default_attr, schema_default_attr)` as `TokenStream`s. +/// - `serde_default_attr`: `#[serde(default = "default_structname_field")]` for deserialization +/// - `schema_default_attr`: `#[schema(default = "value")]` for OpenAPI default value +/// +/// Also generates a companion default function and appends it to `default_functions`. +/// +/// Handles three categories of defaults: +/// 1. **Literal defaults** (`default_value = "42"`, `"draft"`, `0.7`): +/// Generates parse-based default function + schema default. +/// 2. **SQL function defaults** (`default_value = "NOW()"`, `"gen_random_uuid()"`): +/// Generates type-specific default function + schema default with type's zero value. +/// 3. **Primary key** (implicit auto-increment): +/// Treated as having an implicit default — generates type-specific default. +/// +/// Skips serde default generation when: +/// - The field is wrapped in `Option` (partial mode or already optional) +/// - The field already has `#[serde(default)]` +/// - For literal defaults: the field type doesn't implement `FromStr` +pub(super) fn generate_sea_orm_default_attrs( + original_attrs: &[syn::Attribute], + struct_name: &syn::Ident, + field_name: &str, + original_ty: &syn::Type, + field_ty: &dyn quote::ToTokens, + is_optional_or_partial: bool, + default_functions: &mut Vec, +) -> (TokenStream, TokenStream) { + // Don't generate defaults for optional/partial fields + if is_optional_or_partial { + return (quote! {}, quote! {}); + } + + // Check for sea_orm(default_value) and sea_orm(primary_key) + let default_value = extract_sea_orm_default_value(original_attrs); + let has_pk = has_sea_orm_primary_key(original_attrs); + + // No default source found + if default_value.is_none() && !has_pk { + return (quote! {}, quote! {}); + } + + let has_existing_serde_default = extract_default(original_attrs).is_some(); + + match &default_value { + // Literal default (e.g., "42", "draft", "0.7") + Some(value) if !is_sql_function_default(value) => { + let schema_default_attr = quote! { #[schema(default = #value)] }; + + if has_existing_serde_default { + return (quote! {}, schema_default_attr); + } + + if !is_parseable_type(original_ty) { + return (quote! {}, schema_default_attr); + } + + let fn_name = format!("default_{struct_name}_{field_name}"); + let fn_ident = syn::Ident::new(&fn_name, proc_macro2::Span::call_site()); + + default_functions.push(quote! { + #[allow(non_snake_case)] + fn #fn_ident() -> #field_ty { + #value.parse().unwrap() + } + }); + + let serde_default_attr = quote! { #[serde(default = #fn_name)] }; + (serde_default_attr, schema_default_attr) + } + // SQL function default (NOW(), gen_random_uuid(), etc.) or primary_key auto-increment + _ => { + let Some((default_expr, schema_default_str)) = + sql_function_default_for_type(original_ty) + else { + return (quote! {}, quote! {}); + }; + + let schema_default_attr = quote! { #[schema(default = #schema_default_str)] }; + + if has_existing_serde_default { + return (quote! {}, schema_default_attr); + } + + let fn_name = format!("default_{struct_name}_{field_name}"); + let fn_ident = syn::Ident::new(&fn_name, proc_macro2::Span::call_site()); + + default_functions.push(quote! { + #[allow(non_snake_case)] + fn #fn_ident() -> #field_ty { + #default_expr + } + }); + + let serde_default_attr = quote! { #[serde(default = #fn_name)] }; + (serde_default_attr, schema_default_attr) + } + } +} + +/// Return a type-appropriate (Rust default expression, OpenAPI default string) pair +/// for fields with SQL function defaults or implicit auto-increment. +/// +/// The Rust expression is used in the generated `#[serde(default = "fn")]` function body. +/// The OpenAPI string is used in `#[schema(default = "value")]`. +fn sql_function_default_for_type(original_ty: &syn::Type) -> Option<(TokenStream, String)> { + let syn::Type::Path(type_path) = original_ty else { + return None; + }; + let segment = type_path.path.segments.last()?; + let type_name = segment.ident.to_string(); + + match type_name.as_str() { + "DateTimeWithTimeZone" | "DateTimeUtc" | "DateTime" => { + let expr = quote! { + vespera::chrono::DateTime::::UNIX_EPOCH.fixed_offset() + }; + Some((expr, "1970-01-01T00:00:00+00:00".to_string())) + } + "NaiveDateTime" => { + let expr = quote! { + vespera::chrono::NaiveDateTime::UNIX_EPOCH + }; + Some((expr, "1970-01-01T00:00:00".to_string())) + } + "NaiveDate" => { + let expr = quote! { + vespera::chrono::NaiveDate::default() + }; + Some((expr, "1970-01-01".to_string())) + } + "NaiveTime" | "Time" => { + let expr = quote! { + vespera::chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap() + }; + Some((expr, "00:00:00".to_string())) + } + "Uuid" => Some(( + quote! { Default::default() }, + "00000000-0000-0000-0000-000000000000".to_string(), + )), + "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128" + | "usize" | "f32" | "f64" | "Decimal" => { + Some((quote! { Default::default() }, "0".to_string())) + } + "bool" => Some((quote! { Default::default() }, "false".to_string())), + "String" => Some((quote! { Default::default() }, String::new())), + _ => None, + } +} + +/// Check if a type is known to implement `FromStr` and can use `.parse().unwrap()`. +/// +/// Returns true for primitive types, String, and Decimal. +/// Returns false for enums and unknown custom types. +pub(super) fn is_parseable_type(ty: &syn::Type) -> bool { + let syn::Type::Path(type_path) = ty else { + return false; + }; + let Some(segment) = type_path.path.segments.last() else { + return false; + }; + type_utils::PRIMITIVE_TYPE_NAMES.contains(&segment.ident.to_string().as_str()) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + use crate::metadata::StructMetadata; + use crate::schema_macro::{SchemaTypeInput, generate_schema_type_code}; + + fn create_test_struct_metadata(name: &str, definition: &str) -> StructMetadata { + StructMetadata::new(name.to_string(), definition.to_string()) + } + + fn to_storage(items: Vec) -> HashMap { + items.into_iter().map(|s| (s.name.clone(), s)).collect() + } + + // ====================================== + // generate_sea_orm_default_attrs tests + // ====================================== + + #[test] + fn test_sea_orm_default_attrs_optional_field_skips() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "42")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("i32").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "count", &ty, &ty, true, &mut fns); + assert!(serde.is_empty()); + assert!(schema.is_empty()); + assert!(fns.is_empty()); + } + + #[test] + fn test_sea_orm_default_attrs_no_default_and_no_pk() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(unique)])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("String").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "email", + &ty, + &ty, + false, + &mut fns, + ); + assert!(serde.is_empty()); + assert!(schema.is_empty()); + assert!(fns.is_empty()); + } + + #[test] + fn test_sea_orm_default_attrs_primary_key_generates_defaults() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(primary_key)])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("i32").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "id", &ty, &ty, false, &mut fns); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "primary_key should generate serde default: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains('0'), + "primary_key i32 should have schema default 0: {schema_str}" + ); + assert_eq!(fns.len(), 1, "should generate a default function"); + } + + #[test] + fn test_sea_orm_default_attrs_sql_function_generates_defaults() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("DateTimeWithTimeZone").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "created_at", + &ty, + &ty, + false, + &mut fns, + ); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "SQL function default should generate serde default: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains("1970-01-01"), + "DateTimeWithTimeZone should have epoch default: {schema_str}" + ); + assert_eq!(fns.len(), 1, "should generate a default function"); + } + + #[test] + fn test_sea_orm_default_attrs_sql_function_uuid() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(primary_key, default_value = "gen_random_uuid()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("Uuid").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "id", &ty, &ty, false, &mut fns); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "UUID SQL default should generate serde default: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains("00000000-0000-0000-0000-000000000000"), + "Uuid should have nil UUID default: {schema_str}" + ); + assert_eq!(fns.len(), 1); + } + + #[test] + fn test_sea_orm_default_attrs_sql_function_unknown_type_skips() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(default_value = "SOME_FUNC()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("MyCustomType").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "field", + &ty, + &ty, + false, + &mut fns, + ); + assert!(serde.is_empty(), "unknown type should skip serde default"); + assert!(schema.is_empty(), "unknown type should skip schema default"); + assert!(fns.is_empty()); + } + + #[test] + fn test_sea_orm_default_attrs_existing_serde_default() { + let attrs: Vec = vec![ + syn::parse_quote!(#[sea_orm(default_value = "42")]), + syn::parse_quote!(#[serde(default)]), + ]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("i32").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "count", + &ty, + &ty, + false, + &mut fns, + ); + // serde attr should be empty (already has serde default) + assert!(serde.is_empty()); + // schema attr should still be generated + let schema_str = schema.to_string(); + assert!( + schema_str.contains("schema"), + "should have schema attr: {schema_str}" + ); + assert!( + fns.is_empty(), + "no default fn needed when serde(default) exists" + ); + } + + #[test] + fn test_sea_orm_default_attrs_non_parseable_type() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(default_value = "Active")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("MyEnum").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "status", + &ty, + &ty, + false, + &mut fns, + ); + // serde attr empty (non-parseable type) + assert!(serde.is_empty()); + // schema attr still generated + let schema_str = schema.to_string(); + assert!( + schema_str.contains("schema"), + "should have schema attr: {schema_str}" + ); + assert!(fns.is_empty()); + } + + #[test] + fn test_sea_orm_default_attrs_full_generation() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "42")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("i32").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "count", + &ty, + &ty, + false, + &mut fns, + ); + // Both serde and schema attrs should be generated + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "should have serde attr: {serde_str}" + ); + assert!( + serde_str.contains("default_Test_count"), + "should reference generated fn: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains("schema"), + "should have schema attr: {schema_str}" + ); + // Default function should be generated + assert_eq!(fns.len(), 1, "should generate one default function"); + let fn_str = fns[0].to_string(); + assert!( + fn_str.contains("default_Test_count"), + "fn name should match: {fn_str}" + ); + } + + #[test] + fn test_generate_schema_type_code_with_partial_all() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String, pub bio: Option }", + )]); + + let tokens = quote!(UpdateUser from User, partial); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("Option < i32 >")); + assert!(output.contains("Option < String >")); + } + + #[test] + fn test_generate_schema_type_code_with_partial_fields() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String, pub email: String }", + )]); + + let tokens = quote!(UpdateUser from User, partial = ["name"]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!( + output.contains("UpdateUser"), + "should contain generated struct name: {output}" + ); + } + + // ============================================================ + // Coverage: omit_default in generate_schema_type_code (line 180) + // ============================================================ + + #[test] + fn test_generate_schema_type_code_with_omit_default() { + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "items")] + pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, + #[sea_orm(default_value = "NOW()")] + pub created_at: DateTimeWithTimeZone, + }"#, + )]); + + let tokens = quote!(CreateItemRequest from Model, omit_default); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // id (primary_key) and created_at (default_value) should be omitted + assert!( + !output.contains("id :"), + "id should be omitted by omit_default: {output}" + ); + assert!( + !output.contains("created_at"), + "created_at should be omitted by omit_default: {output}" + ); + // name should remain + assert!(output.contains("name"), "name should remain: {output}"); + } + + // ============================================================ + // Coverage: SQL function default with existing serde default (line 554) + // ============================================================ + + #[test] + fn test_sea_orm_default_attrs_sql_function_with_existing_serde_default() { + let attrs: Vec = vec![ + syn::parse_quote!(#[sea_orm(default_value = "NOW()")]), + syn::parse_quote!(#[serde(default)]), + ]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("DateTimeWithTimeZone").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "created_at", + &ty, + &ty, + false, + &mut fns, + ); + // serde attr should be empty (already has serde default) + assert!(serde.is_empty()); + // schema attr should still be generated + let schema_str = schema.to_string(); + assert!( + schema_str.contains("schema"), + "should have schema attr: {schema_str}" + ); + assert!( + schema_str.contains("1970-01-01"), + "should have epoch default: {schema_str}" + ); + assert!( + fns.is_empty(), + "no default fn needed when serde(default) exists" + ); + } + + // ============================================================ + // Coverage: sql_function_default_for_type branches (lines 580-615) + // ============================================================ + + #[test] + fn test_sea_orm_default_attrs_sql_function_non_path_type() { + // Non-Path type (reference) triggers early return None in sql_function_default_for_type + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("&str").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "field", + &ty, + &ty, + false, + &mut fns, + ); + assert!(serde.is_empty(), "non-Path type should skip serde default"); + assert!( + schema.is_empty(), + "non-Path type should skip schema default" + ); + assert!(fns.is_empty()); + } + + #[test] + fn test_sea_orm_default_attrs_sql_function_datetime() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("DateTime").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "created_at", + &ty, + &ty, + false, + &mut fns, + ); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "DateTime should generate serde default: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains("1970-01-01T00:00:00+00:00"), + "DateTime should have epoch default: {schema_str}" + ); + assert_eq!(fns.len(), 1); + } + + #[test] + fn test_sea_orm_default_attrs_sql_function_naive_datetime() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("NaiveDateTime").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "created_at", + &ty, + &ty, + false, + &mut fns, + ); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "NaiveDateTime should generate serde default: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains("1970-01-01T00:00:00"), + "NaiveDateTime should have epoch default: {schema_str}" + ); + assert_eq!(fns.len(), 1); + } + + #[test] + fn test_sea_orm_default_attrs_sql_function_naive_date() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("NaiveDate").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "date_field", + &ty, + &ty, + false, + &mut fns, + ); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "NaiveDate should generate serde default: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains("1970-01-01"), + "NaiveDate should have date default: {schema_str}" + ); + assert_eq!(fns.len(), 1); + } + + #[test] + fn test_sea_orm_default_attrs_sql_function_naive_time() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("NaiveTime").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "time_field", + &ty, + &ty, + false, + &mut fns, + ); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "NaiveTime should generate serde default: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains("00:00:00"), + "NaiveTime should have time default: {schema_str}" + ); + assert_eq!(fns.len(), 1); + } + + #[test] + fn test_sea_orm_default_attrs_sql_function_time_type() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("Time").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "time_field", + &ty, + &ty, + false, + &mut fns, + ); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "Time should generate serde default: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains("00:00:00"), + "Time should have time default: {schema_str}" + ); + assert_eq!(fns.len(), 1); + } + + // --- Coverage: is_parseable_type empty segments --- + + #[test] + fn test_is_parseable_type_empty_segments() { + // Synthetically construct a Type::Path with empty segments (impossible through parsing) + let ty = syn::Type::Path(syn::TypePath { + qself: None, + path: syn::Path { + leading_colon: None, + segments: syn::punctuated::Punctuated::new(), + }, + }); + assert!(!is_parseable_type(&ty)); + } + + #[test] + fn test_generate_schema_type_code_partial_nonexistent_field() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(UpdateUser from User, partial = ["nonexistent"]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("does not exist")); + assert!(err.contains("nonexistent")); + } + + #[test] + fn test_generate_schema_type_code_partial_from_impl_wraps_some() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(UpdateUser from User, partial); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("Some (source . id)")); + assert!(output.contains("Some (source . name)")); + } + + #[test] + fn test_generate_schema_type_code_preserves_struct_doc() { + let input = SchemaTypeInput { + new_type: syn::Ident::new("NewUser", proc_macro2::Span::call_site()), + source_type: syn::parse_str("User").unwrap(), + omit: None, + pick: None, + rename: None, + add: None, + derive_clone: true, + partial: None, + schema_name: None, + ignore_schema: false, + rename_all: None, + multipart: false, + omit_default: false, + }; + let struct_def = StructMetadata { + name: "User".to_string(), + definition: r" + /// User struct documentation + pub struct User { + /// The user ID + pub id: i32, + /// The user name + pub name: String, + } + " + .to_string(), + include_in_openapi: true, + field_defaults: std::collections::BTreeMap::new(), + }; + let storage = to_storage(vec![struct_def]); + let result = generate_schema_type_code(&input, &storage); + assert!(result.is_ok()); + let (tokens, _) = result.unwrap(); + let tokens_str = tokens.to_string(); + assert!(tokens_str.contains("User struct documentation") || tokens_str.contains("doc")); + } + + // Tests for serde attribute filtering from source struct + + #[test] + fn test_generate_schema_type_code_inherits_source_rename_all() { + // Source struct has serde(rename_all = "snake_case") + let storage = to_storage(vec![create_test_struct_metadata( + "User", + r#"#[serde(rename_all = "snake_case")] + pub struct User { pub id: i32, pub user_name: String }"#, + )]); + + let tokens = quote!(UserResponse from User); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should use snake_case from source + assert!(output.contains("rename_all")); + assert!(output.contains("snake_case")); + } + + #[test] + fn test_generate_schema_type_code_override_rename_all() { + // Source has snake_case, but we override with camelCase + let storage = to_storage(vec![create_test_struct_metadata( + "User", + r#"#[serde(rename_all = "snake_case")] + pub struct User { pub id: i32, pub user_name: String }"#, + )]); + + let tokens = quote!(UserResponse from User, rename_all = "camelCase"); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should use camelCase (our override) + assert!(output.contains("camelCase")); + } +} diff --git a/crates/vespera_macro/src/schema_macro/file_cache.rs b/crates/vespera_macro/src/schema_macro/file_cache.rs index 59313549..2802567e 100644 --- a/crates/vespera_macro/src/schema_macro/file_cache.rs +++ b/crates/vespera_macro/src/schema_macro/file_cache.rs @@ -10,6 +10,20 @@ //! are not `Send`/`Sync`, and proc-macros run single-threaded anyway. //! The mtime check handles rust-analyzer's proc-macro server, which may persist //! across file edits. +//! +//! ## Epoch caching +//! +//! `fs::metadata` costs ~1–10 µs per call. Projects with 100+ source files +//! previously paid that cost on every cache lookup, even on hits. +//! +//! The epoch mechanism amortises this: each top-level macro invocation +//! (`vespera!`, `schema_type!`) calls [`bump_epoch`] once at entry. Within +//! that epoch, a given path's mtime is fetched from `fs::metadata` **at most +//! once** and stored in `mtime_epoch_cache`. Subsequent lookups for the same +//! path in the same epoch reuse the cached mtime without a syscall. +//! +//! Across epochs the full mtime check still runs, preserving the existing +//! invalidation semantics (important for rust-analyzer's long-lived server). use std::cell::RefCell; use std::collections::HashMap; @@ -17,6 +31,26 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::SystemTime; +// Test-only thread-local counter: number of `fs::metadata` calls made on +// this thread. Thread-local so parallel test threads don't interfere with +// each other's counts. +#[cfg(test)] +thread_local! { + static METADATA_CALL_COUNT: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Reset the test-only metadata call counter to zero for this thread. +#[cfg(test)] +pub fn reset_metadata_call_count() { + METADATA_CALL_COUNT.with(|c| c.set(0)); +} + +/// Return the current value of the test-only metadata call counter for this thread. +#[cfg(test)] +pub fn metadata_call_count() -> usize { + METADATA_CALL_COUNT.with(std::cell::Cell::get) +} + use super::circular::CircularAnalysis; use super::file_lookup::collect_rs_files_recursive; use crate::metadata::StructMetadata; @@ -24,7 +58,10 @@ use crate::metadata::StructMetadata; /// Internal cache state. struct FileCache { /// Cached `.rs` file lists per source directory. - file_lists: HashMap>, + /// + /// `Arc<[PathBuf]>` so cache hits hand out an O(1) pointer clone + /// instead of cloning every path in the list. + file_lists: HashMap>, /// Cached file contents: file path → (mtime, content string). /// Mtime is checked to invalidate stale entries in long-lived processes. @@ -37,7 +74,8 @@ struct FileCache { /// Struct name candidate index: (src_dir, struct_name) → files containing that name. /// Built from cheap `String::contains` search, not full parsing. - struct_candidates: HashMap<(PathBuf, String), Vec>, + /// `Arc<[PathBuf]>` for O(1) cache-hit clones. + struct_candidates: HashMap<(PathBuf, String), Arc<[PathBuf]>>, // NOTE: We CANNOT cache `syn::File` or `syn::ItemStruct` across proc-macro // invocations. Both `syn` and `proc_macro2` types contain `proc_macro::Span` @@ -63,9 +101,11 @@ struct FileCache { // --- Phase 4 caches --- /// Cached circular reference analysis results: (module_path, definition) → analysis. circular_analysis: HashMap<(String, String), CircularAnalysis>, - /// Cached struct lookups by schema path: path_str → Option. + /// Cached struct lookups by schema path: path_str → Option>. /// `None` values are cached (negative cache) to avoid repeated failed lookups. - struct_lookup: HashMap>, + /// `Arc` because `StructMetadata.definition` holds the full struct + /// source text — cloning it per hit copied kilobytes. + struct_lookup: HashMap>>, /// Cached FK column lookups: (schema_path, via_rel) → Option. fk_column_lookup: HashMap<(String, String), Option>, /// Cached module path extraction from schema paths: path_str → Vec. @@ -83,6 +123,17 @@ struct FileCache { fk_column_cache_hits: usize, module_path_cache_hits: usize, struct_def_cache_hits: usize, + + // --- Epoch caching --- + /// Monotonically increasing counter. Bumped once at the start of each + /// top-level macro invocation (`vespera!`, `schema_type!`). + epoch: u64, + /// Per-epoch mtime cache: path → (epoch_when_checked, mtime_result). + /// + /// When the stored epoch equals `self.epoch`, the mtime was already + /// fetched during this invocation and `fs::metadata` is skipped. + /// When the epoch differs the entry is stale and the syscall runs again. + mtime_epoch_cache: HashMap)>, } thread_local! { @@ -105,9 +156,47 @@ thread_local! { module_path_cache_hits: 0, struct_definitions: HashMap::with_capacity(32), struct_def_cache_hits: 0, + epoch: 0, + mtime_epoch_cache: HashMap::with_capacity(32), + }); +} + +/// Advance the per-invocation epoch counter. +/// +/// Call this **once** at the start of each top-level macro invocation +/// (`vespera!`, `schema_type!`). Within a single epoch, `fs::metadata` is +/// called at most once per path; subsequent lookups for the same path reuse +/// the cached mtime without a syscall. +/// +/// Across epochs the full mtime check still runs, preserving the existing +/// invalidation semantics for long-lived processes (e.g. rust-analyzer). +pub fn bump_epoch() { + FILE_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + cache.epoch = cache.epoch.wrapping_add(1); }); } +/// Fetch the mtime for `path`, using the epoch cache to avoid redundant +/// `fs::metadata` syscalls within a single macro invocation. +/// +/// Returns `None` if the file does not exist or its mtime is unavailable. +fn get_mtime_cached(cache: &mut FileCache, path: &Path) -> Option { + let current_epoch = cache.epoch; + if let Some(&(entry_epoch, mtime)) = cache.mtime_epoch_cache.get(path) + && entry_epoch == current_epoch + { + return mtime; + } + #[cfg(test)] + METADATA_CALL_COUNT.with(|c| c.set(c.get() + 1)); + let mtime = std::fs::metadata(path).ok().and_then(|m| m.modified().ok()); + cache + .mtime_epoch_cache + .insert(path.to_path_buf(), (current_epoch, mtime)); + mtime +} + /// Get `CARGO_MANIFEST_DIR` from cache, or read from env and cache. /// /// Within a single compilation, this value never changes. Caching avoids @@ -153,37 +242,37 @@ fn parse_file_cached(cache: &mut FileCache, path: &Path) -> Option { /// Performs a cheap text-based search (`String::contains`) on file contents. /// False positives are acceptable (struct name in comments/strings), but false /// negatives are not. Results are cached per `(src_dir, struct_name)` pair. -pub fn get_struct_candidates(src_dir: &Path, struct_name: &str) -> Vec { +pub fn get_struct_candidates(src_dir: &Path, struct_name: &str) -> Arc<[PathBuf]> { FILE_CACHE.with(|cache| { let mut cache = cache.borrow_mut(); let key = (src_dir.to_path_buf(), struct_name.to_string()); if let Some(candidates) = cache.struct_candidates.get(&key) { - return candidates.clone(); + return Arc::clone(candidates); } - // Ensure file list is cached - let files = if let Some(files) = cache.file_lists.get(src_dir) { - files.clone() + let files: Arc<[PathBuf]> = if let Some(files) = cache.file_lists.get(src_dir) { + Arc::clone(files) } else { let mut files = Vec::new(); collect_rs_files_recursive(src_dir, &mut files); + let files: Arc<[PathBuf]> = files.into(); cache .file_lists - .insert(src_dir.to_path_buf(), files.clone()); + .insert(src_dir.to_path_buf(), Arc::clone(&files)); files }; - // Filter using cheap text search, caching file contents along the way - let candidates: Vec = files - .into_iter() + let candidates: Arc<[PathBuf]> = files + .iter() .filter(|path| { let content = get_file_content_inner(&mut cache, path); content.is_some_and(|c| c.contains(struct_name)) }) + .cloned() .collect(); - cache.struct_candidates.insert(key, candidates.clone()); + cache.struct_candidates.insert(key, Arc::clone(&candidates)); candidates }) } @@ -191,7 +280,7 @@ pub fn get_struct_candidates(src_dir: &Path, struct_name: &str) -> Vec /// On first call, parses the file and caches all struct definitions as strings. /// On subsequent calls, checks mtime to validate cache. fn ensure_struct_definitions(cache: &mut FileCache, path: &Path) -> bool { - let current_mtime = std::fs::metadata(path).ok().and_then(|m| m.modified().ok()); + let current_mtime = get_mtime_cached(cache, path); if let Some(mtime) = current_mtime && let Some((cached_mtime, _)) = cache.struct_definitions.get(path) @@ -201,8 +290,6 @@ fn ensure_struct_definitions(cache: &mut FileCache, path: &Path) -> bool { return true; } - // Cache miss — parse file and extract all struct definitions. - // Uses parse_file_cached: single syn::parse_file entry point. let Some(file_ast) = parse_file_cached(cache, path) else { return false; }; @@ -254,7 +341,7 @@ pub fn get_struct_definition(path: &Path, struct_name: &str) -> Option { /// Returns `Arc` so callers share a single allocation instead of /// cloning the whole file body per lookup. fn get_file_content_inner(cache: &mut FileCache, path: &Path) -> Option> { - let current_mtime = std::fs::metadata(path).ok().and_then(|m| m.modified().ok()); + let current_mtime = get_mtime_cached(cache, path); if let Some(mtime) = current_mtime && let Some((cached_mtime, content)) = cache.file_contents.get(path) @@ -264,7 +351,6 @@ fn get_file_content_inner(cache: &mut FileCache, path: &Path) -> Option Result CircularAnalysis { let key = (source_module_path.join("::"), definition.to_string()); - // 1. Check cache — borrow dropped at end of closure + // The borrow must end before analyzing: analysis re-enters FILE_CACHE. let cached = FILE_CACHE.with(|cache| cache.borrow().circular_analysis.get(&key).cloned()); if let Some(result) = cached { FILE_CACHE.with(|cache| cache.borrow_mut().circular_cache_hits += 1); return result; } - // 2. Compute — this re-enters FILE_CACHE via parse_struct_cached (safe: our borrow is dropped) let result = super::circular::analyze_circular_refs(source_module_path, definition); - // 3. Store — new borrow FILE_CACHE.with(|cache| { cache .borrow_mut() @@ -323,20 +407,21 @@ pub fn get_circular_analysis(source_module_path: &[String], definition: &str) -> /// Get or compute struct lookup by schema path, with caching. /// -/// Wraps `find_struct_from_schema_path` with a `HashMap>` -/// cache. `None` values are cached too (negative cache) to avoid repeated failed lookups. -pub fn get_struct_from_schema_path(path_str: &str) -> Option { - // 1. Check cache — borrow dropped at end of closure +/// Wraps `find_struct_from_schema_path` with a +/// `HashMap>>` cache. `None` values +/// are cached too (negative cache) to avoid repeated failed lookups. +/// The `Arc` makes cache hits O(1) instead of cloning the full struct +/// definition text per lookup. +pub fn get_struct_from_schema_path(path_str: &str) -> Option> { + // The borrow must end before lookup: lookup re-enters FILE_CACHE. let cached = FILE_CACHE.with(|cache| cache.borrow().struct_lookup.get(path_str).cloned()); if let Some(result) = cached { FILE_CACHE.with(|cache| cache.borrow_mut().struct_lookup_cache_hits += 1); return result; } - // 2. Compute — this re-enters FILE_CACHE via get_struct_definition (safe: our borrow is dropped) - let result = super::file_lookup::find_struct_from_schema_path(path_str); + let result = super::file_lookup::find_struct_from_schema_path(path_str).map(Arc::new); - // 3. Store — new borrow FILE_CACHE.with(|cache| { cache .borrow_mut() @@ -354,17 +439,15 @@ pub fn get_struct_from_schema_path(path_str: &str) -> Option { pub fn get_fk_column(schema_path: &str, via_rel: &str) -> Option { let key = (schema_path.to_string(), via_rel.to_string()); - // 1. Check cache — borrow dropped at end of closure + // The borrow must end before lookup: lookup re-enters FILE_CACHE. let cached = FILE_CACHE.with(|cache| cache.borrow().fk_column_lookup.get(&key).cloned()); if let Some(result) = cached { FILE_CACHE.with(|cache| cache.borrow_mut().fk_column_cache_hits += 1); return result; } - // 2. Compute — this re-enters FILE_CACHE via get_struct_definition (safe: our borrow is dropped) let result = super::file_lookup::find_fk_column_from_target_entity(schema_path, via_rel); - // 3. Store — new borrow FILE_CACHE.with(|cache| { cache .borrow_mut() @@ -383,26 +466,20 @@ pub fn get_fk_column(schema_path: &str, via_rel: &str) -> Option { pub fn get_module_path_from_schema_path(schema_path: &proc_macro2::TokenStream) -> Vec { let path_str = schema_path.to_string(); - // 1. Check cache — borrow dropped at end of closure let cached = FILE_CACHE.with(|cache| cache.borrow().module_path_cache.get(&path_str).cloned()); if let Some(result) = cached { FILE_CACHE.with(|cache| cache.borrow_mut().module_path_cache_hits += 1); return result; } - // 2. Compute directly: collect once, pop the trailing schema segment. - // The previous version built an intermediate `Vec<&str>` and then - // re-allocated it into a `Vec` (one wasted allocation per - // cache miss). let mut result: Vec = path_str .split("::") .map(str::trim) .filter(|s| !s.is_empty()) .map(ToString::to_string) .collect(); - result.pop(); // drop the trailing segment (the schema name itself) + result.pop(); - // 3. Store — new borrow FILE_CACHE.with(|cache| { cache .borrow_mut() @@ -542,21 +619,16 @@ mod tests { ) .unwrap(); - // First call: populates file_lists cache for src_dir let result1 = get_struct_candidates(src_dir, "Alpha"); assert_eq!(result1.len(), 1); - // Second call: same src_dir, different struct_name - // struct_candidates cache MISS (different key), but file_lists cache HIT → line 125 let result2 = get_struct_candidates(src_dir, "Beta"); assert_eq!(result2.len(), 1); } #[test] fn test_get_fk_column_cache_hit() { - // First call: computes and caches result (None since path doesn't exist) let result1 = get_fk_column("nonexistent::path::Schema", "SomeRelation"); - // Second call: hits cache → lines 259-260 let result2 = get_fk_column("nonexistent::path::Schema", "SomeRelation"); assert_eq!(result1, result2); } @@ -564,24 +636,148 @@ mod tests { #[serial_test::serial] #[test] fn test_print_profile_summary_with_profile_env() { - // Set VESPERA_PROFILE to enable profiling output unsafe { std::env::set_var("VESPERA_PROFILE", "1") }; - // This should print profile summary to stderr (lines 311-321) print_profile_summary(); - // Clean up unsafe { std::env::remove_var("VESPERA_PROFILE") }; - // Test passes if no panic — output goes to stderr } #[serial_test::serial] #[test] fn test_print_profile_summary_without_profile_env() { - // Ensure VESPERA_PROFILE is not set unsafe { std::env::remove_var("VESPERA_PROFILE") }; - // Should early-return at line 308 without printing anything print_profile_summary(); } + + /// Verify that within one epoch a path's mtime is checked via `fs::metadata` + /// exactly once, and that bumping the epoch causes a re-check. + /// + /// Layout: + /// epoch N → read path twice → 1 metadata call (second read hits epoch cache) + /// bump → epoch N+1 + /// epoch N+1 → read path once → 1 more metadata call (epoch cache stale) + /// + /// Total expected: 2 metadata calls for 3 reads across 2 epochs. + #[serial_test::serial] + #[test] + fn test_epoch_skips_metadata_syscall() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("target.rs"); + std::fs::write(&file_path, "pub struct Foo { pub x: i32 }").unwrap(); + + // Reset the global counter and start a fresh epoch so this test is + // independent of whatever other tests ran on this thread before. + reset_metadata_call_count(); + bump_epoch(); + + let before = metadata_call_count(); + + // First read in epoch N — must call fs::metadata (epoch cache miss). + let c1 = get_struct_definition(&file_path, "Foo"); + assert!(c1.is_some(), "struct should be found"); + assert_eq!( + metadata_call_count() - before, + 1, + "first read should trigger exactly 1 metadata call" + ); + + // Second read in epoch N — epoch cache hit, no additional metadata call. + let c2 = get_struct_definition(&file_path, "Foo"); + assert_eq!(c1, c2); + assert_eq!( + metadata_call_count() - before, + 1, + "second read in same epoch must NOT call metadata again" + ); + + // Advance to epoch N+1. + bump_epoch(); + + // First read in epoch N+1 — epoch cache is stale, must re-check metadata. + let c3 = get_struct_definition(&file_path, "Foo"); + assert_eq!(c1, c3); + assert_eq!( + metadata_call_count() - before, + 2, + "read after epoch bump must call metadata exactly once more" + ); + } + + /// Verify cross-entry invalidation semantics. + /// + /// In a long-lived rust-analyzer proc-macro server the same thread handles + /// multiple successive macro invocations. Each entry point (`derive_schema`, + /// `schema_type!`, `schema!`, `export_app!`, `vespera!`) calls `bump_epoch()` + /// as its first statement. This test simulates two successive invocations + /// from *different* entry points and confirms that: + /// + /// 1. Within invocation A (epoch N): path checked once, second access free. + /// 2. Invocation B starts (epoch N+1 via bump): path re-checked exactly once. + /// 3. Within invocation B: second access still free. + /// + /// The test uses `bump_epoch()` directly (the same call each entry point + /// makes) so it exercises the exact mechanism without needing a real + /// proc-macro expansion. + /// + /// NOTE: `bump_epoch()` is the *only* mechanism that separates invocations; + /// the call sites in lib.rs are the authoritative hook locations: + /// - `derive_schema` → reaches file_cache via extract_field_defaults_from_path + /// - `schema` → reaches file_cache via parse_struct_cached + /// - `schema_type!` → reaches file_cache via generate_schema_type_code + /// - `export_app!` → reaches file_cache via collect_metadata + /// - `vespera!` → reaches file_cache via collect_metadata + #[serial_test::serial] + #[test] + fn test_epoch_cross_entry_invalidation() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("cross.rs"); + std::fs::write(&file_path, "pub struct Bar { pub y: u64 }").unwrap(); + + reset_metadata_call_count(); + + // ── Invocation A (simulates e.g. derive_schema entry) ────────────── + bump_epoch(); // what every entry point does first + let before_a = metadata_call_count(); + + let r1 = get_struct_definition(&file_path, "Bar"); + assert!(r1.is_some()); + assert_eq!( + metadata_call_count() - before_a, + 1, + "invocation A: first access must call metadata once" + ); + + // Second access within the same invocation — epoch cache hit. + let r2 = get_struct_definition(&file_path, "Bar"); + assert_eq!(r1, r2); + assert_eq!( + metadata_call_count() - before_a, + 1, + "invocation A: second access must NOT call metadata again" + ); + + // ── Invocation B (simulates e.g. schema_type! entry) ─────────────── + bump_epoch(); // new invocation → new epoch + let before_b = metadata_call_count(); + + // First access in invocation B — epoch cache stale, must re-check. + let r3 = get_struct_definition(&file_path, "Bar"); + assert_eq!(r1, r3); + assert_eq!( + metadata_call_count() - before_b, + 1, + "invocation B: first access must re-check metadata (cross-entry invalidation)" + ); + + // Second access within invocation B — epoch cache hit again. + let r4 = get_struct_definition(&file_path, "Bar"); + assert_eq!(r1, r4); + assert_eq!( + metadata_call_count() - before_b, + 1, + "invocation B: second access must NOT call metadata again" + ); + } } diff --git a/crates/vespera_macro/src/schema_macro/file_lookup.rs b/crates/vespera_macro/src/schema_macro/file_lookup.rs index 53f87f04..b47c6f59 100644 --- a/crates/vespera_macro/src/schema_macro/file_lookup.rs +++ b/crates/vespera_macro/src/schema_macro/file_lookup.rs @@ -1,1647 +1,358 @@ -//! File system operations for finding struct definitions -//! -//! Provides functions to locate struct definitions in source files. +//! File system operations for finding struct definitions. -use std::path::Path; +mod fk; +mod lookup; -use syn::Type; +#[allow(unused_imports)] +pub use fk::find_fk_column_from_target_entity; +#[allow(unused_imports)] +pub use lookup::{ + collect_rs_files_recursive, file_path_to_module_path, find_model_from_schema_path, + find_struct_by_name_in_all_files, find_struct_from_path, find_struct_from_schema_path, +}; -use crate::metadata::StructMetadata; -use std::path::PathBuf; - -/// Build candidate file paths from module segments. -/// -/// Given a source directory and module segments (e.g., `["models", "memo"]`), -/// returns both `{src_dir}/models/memo.rs` and `{src_dir}/models/memo/mod.rs`. -#[inline] -fn candidate_file_paths(src_dir: &Path, module_segments: &[&str]) -> [PathBuf; 2] { - let joined = module_segments.join("/"); - [ - src_dir.join(format!("{joined}.rs")), - src_dir.join(format!("{joined}/mod.rs")), - ] -} - -/// Try to find a struct definition from a module path by reading source files. -/// -/// This allows `schema_type`! to work with structs defined in other files, like: -/// ```ignore -/// // In src/routes/memos.rs -/// schema_type!(CreateMemoRequest from models::memo::Model, pick = ["title", "content"]); -/// ``` -/// -/// The function will: -/// 1. Parse the path (e.g., `models::memo::Model` or `crate::models::memo::Model`) -/// 2. Convert to file path (e.g., `src/models/memo.rs`) -/// 3. Read and parse the file to find the struct definition -/// -/// For simple names (e.g., just `Model` without module path), it will scan all `.rs` -/// files in `src/` to find the struct. This supports same-file usage like: -/// ```ignore -/// pub struct Model { ... } -/// vespera::schema_type!(Schema from Model, name = "UserSchema"); -/// ``` -/// -/// The `schema_name_hint` is used to disambiguate when multiple structs with the same -/// name exist. For example, with `name = "UserSchema"`, it will prefer `user.rs`. -/// -/// Returns `(StructMetadata, Vec)` where the Vec is the module path. -/// For qualified paths, this is extracted from the type itself. -/// For simple names, it's inferred from the file location. -pub fn find_struct_from_path( - ty: &Type, - schema_name_hint: Option<&str>, -) -> Option<(StructMetadata, Vec)> { - // Get CARGO_MANIFEST_DIR to locate src folder (cached to avoid repeated syscalls) - let manifest_dir = super::file_cache::get_manifest_dir()?; - let src_dir = Path::new(&manifest_dir).join("src"); - - // Extract path segments from the type - let Type::Path(type_path) = ty else { - return None; - }; - - let segments: Vec = type_path - .path - .segments - .iter() - .map(|s| s.ident.to_string()) - .collect(); - - if segments.is_empty() { - return None; - } - - // The last segment is the struct name - let struct_name = segments.last()?.clone(); - - // Build possible file paths from the module path - // e.g., models::memo::Model -> src/models/memo.rs or src/models/memo/mod.rs - // e.g., crate::models::memo::Model -> src/models/memo.rs - let module_segments: Vec<&str> = segments[..segments.len() - 1] - .iter() - .filter(|s| *s != "crate" && *s != "self" && *s != "super") - .map(std::string::String::as_str) - .collect(); - - // If no module path (simple name like `Model`), scan all files with schema_name hint - if module_segments.is_empty() { - return find_struct_by_name_in_all_files(&src_dir, &struct_name, schema_name_hint); - } - - // For qualified paths, the module path is extracted from the type itself - // e.g., crate::models::memo::Model -> ["crate", "models", "memo"] - let type_module_path: Vec = segments[..segments.len() - 1].to_vec(); - - // Try different file path patterns - let file_paths = candidate_file_paths(&src_dir, &module_segments); - - for file_path in file_paths { - if !file_path.exists() { - continue; - } - if let Some(definition) = super::file_cache::get_struct_definition(&file_path, &struct_name) - { - return Some(( - StructMetadata::new_model(struct_name, definition), - type_module_path, - )); - } - } - - None -} - -/// Find a struct by name by scanning all `.rs` files in the src directory. -/// -/// This is used as a fallback when the type path doesn't include module information -/// (e.g., just `Model` instead of `crate::models::user::Model`). -/// -/// Resolution strategy: -/// 1. If exactly one struct with the name exists -> use it -/// 2. If multiple exist and `schema_name_hint` is provided (e.g., "UserSchema"): -/// -> Prefer file whose name contains the hint prefix (e.g., "user.rs" for "`UserSchema`") -/// 3. Otherwise -> return None (ambiguous) -/// -/// The `schema_name_hint` is the custom schema name (e.g., "`UserSchema`", "`MemoSchema`") -/// which often contains a hint about the module name. -/// -/// Returns `(StructMetadata, Vec)` where the Vec is the inferred module path -/// from the file location (e.g., `["crate", "models", "user"]`). -#[allow(clippy::too_many_lines)] -pub fn find_struct_by_name_in_all_files( - src_dir: &Path, - struct_name: &str, - schema_name_hint: Option<&str>, -) -> Option<(StructMetadata, Vec)> { - // Use cached struct-candidate index: files already filtered by text search - let mut rs_files = super::file_cache::get_struct_candidates(src_dir, struct_name); - - // Pre-compute hint prefix once (used in fast path and fallback disambiguation) - let prefix_normalized = schema_name_hint.map(derive_hint_prefix); - - // FAST PATH: If schema_name_hint is provided, try matching files first. - // This avoids parsing ALL files for the common same-file pattern: - // schema_type!(Schema from Model, name = "UserSchema") in user.rs - if let Some(prefix_normalized) = &prefix_normalized { - // Partition files: candidate files (filename matches hint prefix) vs rest - let (candidates, rest): (Vec<_>, Vec<_>) = rs_files.into_iter().partition(|path| { - path.file_stem() - .and_then(|s| s.to_str()) - .is_some_and(|name| { - let norm = normalize_name(name); - norm == *prefix_normalized || norm.contains(prefix_normalized.as_str()) - }) - }); - - // Parse only candidate files first - let mut found_in_candidates: Vec<(std::path::PathBuf, StructMetadata)> = Vec::new(); - for file_path in &candidates { - if let Some(definition) = - super::file_cache::get_struct_definition(file_path, struct_name) - { - found_in_candidates.push(( - file_path.clone(), - StructMetadata::new_model(struct_name.to_string(), definition), - )); - } - } - - // If exactly one match in candidates, return immediately (fast path hit!) - if found_in_candidates.len() == 1 { - let (path, metadata) = found_in_candidates.remove(0); - let module_path = file_path_to_module_path(&path, src_dir); - return Some((metadata, module_path)); - } - - // If candidates found multiple, try disambiguation by exact filename match - if found_in_candidates.len() > 1 { - let exact_match: Vec<_> = found_in_candidates - .iter() - .filter(|(path, _)| { - path.file_stem() - .and_then(|s| s.to_str()) - .is_some_and(|name| normalize_name(name) == *prefix_normalized) - }) - .collect(); - - if exact_match.len() == 1 { - let (path, metadata) = exact_match[0]; - let module_path = file_path_to_module_path(path, src_dir); - return Some((metadata.clone(), module_path)); - } - - // Still ambiguous among candidates - return None; - } - - // No match in candidates — fall through to scan remaining files - rs_files = rest; - } - - // FULL SCAN: Parse all remaining files (or all files if no hint) - let mut found_structs: Vec<(std::path::PathBuf, StructMetadata)> = Vec::new(); - - for file_path in rs_files { - if let Some(definition) = super::file_cache::get_struct_definition(&file_path, struct_name) - { - found_structs.push(( - file_path.clone(), - StructMetadata::new_model(struct_name.to_string(), definition), - )); - } - } - - match found_structs.len() { - 1 => { - let (path, metadata) = found_structs.remove(0); - let module_path = file_path_to_module_path(&path, src_dir); - Some((metadata, module_path)) - } - _ => None, - } -} - -/// Derive a normalized prefix from a schema name hint for file matching. -/// -/// Strips common suffixes ("Schema", "Response", "Request") and normalizes -/// by removing underscores and lowercasing. -/// -/// # Examples -/// - "UserSchema" → "user" -/// - "MemoResponse" → "memo" -/// - "AdminUserSchema" → "adminuser" -fn derive_hint_prefix(hint: &str) -> String { - let hint_lower = hint.to_lowercase(); - let prefix = hint_lower - .strip_suffix("schema") - .or_else(|| hint_lower.strip_suffix("response")) - .or_else(|| hint_lower.strip_suffix("request")) - .unwrap_or(&hint_lower); - normalize_name(prefix) -} - -/// Normalize a name by lowercasing and removing underscores in a single pass. -/// Replaces the two-allocation `s.to_lowercase().replace('_', "")` pattern. -#[inline] -fn normalize_name(s: &str) -> String { - s.chars() - .filter(|&c| c != '_') - .map(|c| c.to_ascii_lowercase()) - .collect() -} - -/// Recursively collect all `.rs` files in a directory. -pub fn collect_rs_files_recursive(dir: &Path, files: &mut Vec) { - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - collect_rs_files_recursive(&path, files); - } else if path.extension().is_some_and(|ext| ext == "rs") { - files.push(path); - } - } -} - -/// Derive module path from a file path relative to src directory. -/// -/// Examples: -/// - `src/models/user.rs` -> `["crate", "models", "user"]` -/// - `src/models/user/mod.rs` -> `["crate", "models", "user"]` -/// - `src/lib.rs` -> `["crate"]` -pub fn file_path_to_module_path(file_path: &Path, src_dir: &Path) -> Vec { - let Ok(relative) = file_path.strip_prefix(src_dir) else { - return vec!["crate".to_string()]; - }; - - let mut segments = vec!["crate".to_string()]; - - for component in relative.components() { - if let std::path::Component::Normal(os_str) = component - && let Some(s) = os_str.to_str() - { - // Handle .rs extension - if let Some(name) = s.strip_suffix(".rs") { - // Skip mod.rs and lib.rs - they don't add a segment - if name != "mod" && name != "lib" { - segments.push(name.to_string()); - } - } else { - // Directory name - segments.push(s.to_string()); - } - } - } - - segments -} - -/// Find struct definition from a schema path string (e.g., "`crate::models::user::Schema`"). -/// -/// Similar to `find_struct_from_path` but takes a string path instead of `syn::Type`. -pub fn find_struct_from_schema_path(path_str: &str) -> Option { - // Get CARGO_MANIFEST_DIR to locate src folder (cached to avoid repeated syscalls) - let manifest_dir = super::file_cache::get_manifest_dir()?; - let src_dir = Path::new(&manifest_dir).join("src"); - - // Parse the path string into segments - let segments: Vec<&str> = path_str.split("::").filter(|s| !s.is_empty()).collect(); - - if segments.is_empty() { - return None; - } - - // The last segment is the struct name - let struct_name = segments.last()?.to_string(); - - // Build possible file paths from the module path - // e.g., crate::models::user::Schema -> src/models/user.rs - let module_segments: Vec<&str> = segments[..segments.len() - 1] - .iter() - .filter(|s| **s != "crate" && **s != "self" && **s != "super") - .copied() - .collect(); - - if module_segments.is_empty() { - return None; - } - - // Try different file path patterns - let file_paths = candidate_file_paths(&src_dir, &module_segments); - - for file_path in file_paths { - if !file_path.exists() { - continue; - } - if let Some(definition) = super::file_cache::get_struct_definition(&file_path, &struct_name) - { - return Some(StructMetadata::new_model(struct_name, definition)); - } - } - - None -} - -/// Find the FK column name from the target entity for a `HasMany` relation with `via_rel`. -/// -/// When a `HasMany` relation has `via_rel = "TargetUser"`, this function: -/// 1. Looks up the target entity file (e.g., notification.rs from schema path) -/// 2. Finds the field with matching `relation_enum = "TargetUser"` -/// 3. Extracts and returns the `from` attribute value (e.g., "`target_user_id`") -/// -/// Returns None if the target file can't be found or parsed, or if no matching relation exists. -#[allow(clippy::too_many_lines)] -pub fn find_fk_column_from_target_entity( - target_schema_path: &str, - via_rel: &str, -) -> Option { - use crate::schema_macro::seaorm::{extract_belongs_to_from_field, extract_relation_enum}; - - // Get CARGO_MANIFEST_DIR to locate src folder (cached to avoid repeated syscalls) - let manifest_dir = super::file_cache::get_manifest_dir()?; - let src_dir = Path::new(&manifest_dir).join("src"); - - // Parse the schema path to get file path - // e.g., "crate :: models :: notification :: Schema" -> src/models/notification.rs - let segments: Vec<&str> = target_schema_path - .split("::") - .map(str::trim) - .filter(|s| !s.is_empty() && *s != "Schema" && *s != "Entity") - .collect(); - - let module_segments: Vec<&str> = segments - .iter() - .filter(|s| **s != "crate" && **s != "self" && **s != "super") - .copied() - .collect(); - - if module_segments.is_empty() { - return None; - } - - // Try different file path patterns - let file_paths = candidate_file_paths(&src_dir, &module_segments); - - for file_path in file_paths { - if !file_path.exists() { - continue; - } - - let Some(model_def) = super::file_cache::get_struct_definition(&file_path, "Model") else { - continue; - }; - let Ok(model) = super::file_cache::parse_struct_cached(&model_def) else { - continue; - }; - - // Search through fields for the one with matching relation_enum - if let syn::Fields::Named(fields_named) = &model.fields { - for field in &fields_named.named { - let field_relation_enum = extract_relation_enum(&field.attrs); - if field_relation_enum.as_deref() == Some(via_rel) { - // Found the matching field, extract FK column from `from` attribute - return extract_belongs_to_from_field(&field.attrs); - } - } - } - } - - None -} - -/// Find the Model definition from a Schema path. -/// Converts "`crate::models::user::Schema`" -> finds Model in src/models/user.rs -#[allow(clippy::too_many_lines)] -pub fn find_model_from_schema_path(schema_path_str: &str) -> Option { - // Get CARGO_MANIFEST_DIR to locate src folder (cached to avoid repeated syscalls) - let manifest_dir = super::file_cache::get_manifest_dir()?; - let src_dir = Path::new(&manifest_dir).join("src"); - - // Parse the path string and convert Schema path to module path - // e.g., "crate :: models :: user :: Schema" -> ["crate", "models", "user"] - let segments: Vec<&str> = schema_path_str - .split("::") - .map(str::trim) - .filter(|s| !s.is_empty() && *s != "Schema") - .collect(); - - if segments.is_empty() { - return None; - } - - // Build possible file paths from the module path - let module_segments: Vec<&str> = segments - .iter() - .filter(|s| **s != "crate" && **s != "self" && **s != "super") - .copied() - .collect(); - - if module_segments.is_empty() { - return None; - } - - // Try different file path patterns - let file_paths = candidate_file_paths(&src_dir, &module_segments); - - for file_path in file_paths { - if !file_path.exists() { - continue; - } - if let Some(definition) = super::file_cache::get_struct_definition(&file_path, "Model") { - return Some(StructMetadata::new_model("Model".to_string(), definition)); - } - } - - None -} - -#[cfg(test)] -mod tests { - use std::path::Path; - - use serial_test::serial; - use tempfile::TempDir; - - use super::*; - - #[test] - fn test_file_path_to_module_path_simple() { - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - let file_path = src_dir.join("models").join("user.rs"); - let result = file_path_to_module_path(&file_path, src_dir); - assert_eq!(result, vec!["crate", "models", "user"]); - } - - #[test] - fn test_file_path_to_module_path_mod_rs() { - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - let file_path = src_dir.join("models").join("mod.rs"); - let result = file_path_to_module_path(&file_path, src_dir); - assert_eq!(result, vec!["crate", "models"]); - } - - #[test] - fn test_file_path_to_module_path_lib_rs() { - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - let file_path = src_dir.join("lib.rs"); - let result = file_path_to_module_path(&file_path, src_dir); - assert_eq!(result, vec!["crate"]); - } - - #[test] - fn test_file_path_to_module_path_not_under_src() { - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let file_path = temp_dir.path().join("other").join("file.rs"); - let result = file_path_to_module_path(&file_path, &src_dir); - assert_eq!(result, vec!["crate"]); - } - - #[test] - fn test_collect_rs_files_recursive_empty_dir() { - let temp_dir = TempDir::new().unwrap(); - let mut files = Vec::new(); - collect_rs_files_recursive(temp_dir.path(), &mut files); - assert!(files.is_empty()); - } - - #[test] - fn test_collect_rs_files_recursive_nonexistent_dir() { - let mut files = Vec::new(); - collect_rs_files_recursive(Path::new("/nonexistent/path"), &mut files); - assert!(files.is_empty()); - } - - #[test] - fn test_collect_rs_files_recursive_with_files() { - let temp_dir = TempDir::new().unwrap(); - - // Create some .rs files - std::fs::write(temp_dir.path().join("main.rs"), "fn main() {}").unwrap(); - std::fs::create_dir(temp_dir.path().join("models")).unwrap(); - std::fs::write( - temp_dir.path().join("models").join("user.rs"), - "struct User;", - ) - .unwrap(); - std::fs::write(temp_dir.path().join("other.txt"), "not a rust file").unwrap(); - - let mut files = Vec::new(); - collect_rs_files_recursive(temp_dir.path(), &mut files); - - assert_eq!(files.len(), 2); - assert!(files.iter().all(|f| f.extension().unwrap() == "rs")); - } - - // ============================================================ - // Coverage tests for find_struct_from_path - // ============================================================ - - #[test] - fn test_find_struct_from_path_non_path_type() { - // Tests: Type is not a Path type -> returns None - use syn::Type; - - // Create a reference type (not a path type) - let ty: Type = syn::parse_str("&str").unwrap(); - - // Save original CARGO_MANIFEST_DIR - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - - // Set a temporary manifest dir (doesn't matter since we'll return early) - let temp_dir = TempDir::new().unwrap(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let result = find_struct_from_path(&ty, None); - - // Restore - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_none(), "Non-path type should return None"); - } - - #[test] - fn test_find_struct_from_path_empty_segments() { - // Tests: Type path with empty segments -> returns None - use syn::{Path, TypePath}; - - // Construct a TypePath with empty segments - let empty_path = Path { - leading_colon: None, - segments: syn::punctuated::Punctuated::new(), - }; - let ty = Type::Path(TypePath { - qself: None, - path: empty_path, - }); - - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - let temp_dir = TempDir::new().unwrap(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let result = find_struct_from_path(&ty, None); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_none(), "Empty segments should return None"); - } - - #[test] - #[serial] - fn test_find_struct_from_path_file_with_non_matching_items() { - // Tests: File contains items that are not the target struct - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create a file with multiple items, only one matching - let content = r" -pub enum SomeEnum { A, B } -pub fn some_function() {} -pub const SOME_CONST: i32 = 42; -pub trait SomeTrait {} -pub struct NotTarget { pub x: i32 } -pub struct Target { pub id: i32 } -"; - std::fs::write(models_dir.join("mixed.rs"), content).unwrap(); - - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let ty: Type = syn::parse_str("crate::models::mixed::Target").unwrap(); - let result = find_struct_from_path(&ty, None); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_some(), "Should find Target struct"); - let (metadata, _) = result.unwrap(); - assert!(metadata.definition.contains("Target")); - } - - // ============================================================ - // Coverage tests for find_struct_by_name_in_all_files - // ============================================================ - - #[test] - #[serial] - fn test_find_struct_by_name_unreadable_file() { - // Tests for error continuation - // Create broken symlink that exists but can't be read - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - // Valid file - std::fs::write( - src_dir.join("valid.rs"), - "pub struct Target { pub id: i32 }", - ) - .unwrap(); - - // Broken symlink -> read_to_string fails -> line 122 - let broken = src_dir.join("broken.rs"); - let nonexistent = src_dir.join("nonexistent"); - #[cfg(unix)] - let _ = std::os::unix::fs::symlink(&nonexistent, &broken); - #[cfg(windows)] - let _ = std::os::windows::fs::symlink_file(&nonexistent, &broken); - - let result = find_struct_by_name_in_all_files(src_dir, "Target", None); - - assert!( - result.is_some(), - "Should find Target, skipping broken symlink" - ); - } - - #[test] - #[serial] - fn test_find_struct_by_name_unparseable_file() { - // Tests: File cannot be parsed -> continue to next file - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - // Create an unparseable file - std::fs::write(src_dir.join("broken.rs"), "this is not valid rust {{{{").unwrap(); - - // Create a valid file with the struct - std::fs::write( - src_dir.join("valid.rs"), - "pub struct Target { pub id: i32 }", - ) - .unwrap(); - - let result = find_struct_by_name_in_all_files(src_dir, "Target", None); - - assert!( - result.is_some(), - "Should find Target in valid file, skipping broken" - ); - } - - #[test] - #[serial] - fn test_find_struct_disambiguation_with_hint() { - // Tests: Multiple structs with same name, schema_name_hint disambiguates - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - // Create user.rs with Model - std::fs::create_dir(src_dir.join("models")).unwrap(); - std::fs::write( - src_dir.join("models").join("user.rs"), - "pub struct Model { pub id: i32, pub name: String }", - ) - .unwrap(); - - // Create memo.rs with Model (same struct name) - std::fs::write( - src_dir.join("models").join("memo.rs"), - "pub struct Model { pub id: i32, pub title: String }", - ) - .unwrap(); - - // Without hint - should return None (ambiguous) - let result_no_hint = find_struct_by_name_in_all_files(src_dir, "Model", None); - assert!( - result_no_hint.is_none(), - "Without hint, multiple Models should be ambiguous" - ); - - // With hint "UserSchema" - should find user.rs - let result_with_hint = - find_struct_by_name_in_all_files(src_dir, "Model", Some("UserSchema")); - assert!( - result_with_hint.is_some(), - "With UserSchema hint, should find user.rs" - ); - let (metadata, module_path) = result_with_hint.unwrap(); - assert!( - metadata.definition.contains("name"), - "Should be user Model with name field" - ); - assert!( - module_path.contains(&"user".to_string()), - "Module path should contain 'user'" - ); - - // With hint "MemoSchema" - should find memo.rs - let result_memo = find_struct_by_name_in_all_files(src_dir, "Model", Some("MemoSchema")); - assert!( - result_memo.is_some(), - "With MemoSchema hint, should find memo.rs" - ); - let (metadata_memo, _) = result_memo.unwrap(); - assert!( - metadata_memo.definition.contains("title"), - "Should be memo Model with title field" - ); - } - - #[test] - #[serial] - fn test_find_struct_disambiguation_with_response_suffix() { - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - std::fs::create_dir(src_dir.join("models")).unwrap(); - std::fs::write( - src_dir.join("models").join("user.rs"), - "pub struct Data { pub id: i32 }", - ) - .unwrap(); - std::fs::write( - src_dir.join("models").join("item.rs"), - "pub struct Data { pub name: String }", - ) - .unwrap(); - - // With hint "UserResponse" - should find user.rs - let result = find_struct_by_name_in_all_files(src_dir, "Data", Some("UserResponse")); - assert!( - result.is_some(), - "With UserResponse hint, should find user.rs" - ); - } - - #[test] - #[serial] - fn test_find_struct_disambiguation_with_request_suffix() { - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - std::fs::create_dir(src_dir.join("models")).unwrap(); - std::fs::write( - src_dir.join("models").join("user.rs"), - "pub struct Input { pub id: i32 }", - ) - .unwrap(); - std::fs::write( - src_dir.join("models").join("item.rs"), - "pub struct Input { pub name: String }", - ) - .unwrap(); - - // With hint "UserRequest" - should find user.rs - let result = find_struct_by_name_in_all_files(src_dir, "Input", Some("UserRequest")); - assert!( - result.is_some(), - "With UserRequest hint, should find user.rs" - ); - } - - #[test] - #[serial] - fn test_find_struct_disambiguation_still_ambiguous() { - // Tests: Multiple matches even after applying hint -> returns None - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - // Create two files that both match the hint - std::fs::create_dir(src_dir.join("models")).unwrap(); - std::fs::write( - src_dir.join("models").join("user_admin.rs"), - "pub struct Model { pub id: i32 }", - ) - .unwrap(); - std::fs::write( - src_dir.join("models").join("user_regular.rs"), - "pub struct Model { pub name: String }", - ) - .unwrap(); - - // With hint "UserSchema" - both user_admin.rs and user_regular.rs match - let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("UserSchema")); - assert!( - result.is_none(), - "Multiple files matching hint should still be ambiguous" - ); - } - - #[test] - #[serial] - fn test_find_struct_disambiguation_snake_case_filename() { - // Tests: CamelCase schema name matches snake_case filename - // e.g., "AdminUserSchema" should match "admin_user.rs" - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - std::fs::create_dir(src_dir.join("models")).unwrap(); - // Create admin_user.rs with Model - std::fs::write( - src_dir.join("models").join("admin_user.rs"), - "pub struct Model { pub id: i32, pub role: String }", - ) - .unwrap(); - // Create regular_user.rs with Model - std::fs::write( - src_dir.join("models").join("regular_user.rs"), - "pub struct Model { pub id: i32, pub name: String }", - ) - .unwrap(); - - // With hint "AdminUserSchema" - should find admin_user.rs - // "AdminUserSchema" -> prefix "adminuser" -> matches "admin_user.rs" (normalized: "adminuser") - let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("AdminUserSchema")); - assert!( - result.is_some(), - "AdminUserSchema hint should match admin_user.rs" - ); - let (metadata, module_path) = result.unwrap(); - assert!( - metadata.definition.contains("role"), - "Should be admin_user Model with role field" - ); - assert!( - module_path.contains(&"admin_user".to_string()), - "Module path should contain 'admin_user'" - ); - - // With hint "RegularUserSchema" - should find regular_user.rs - let result_regular = - find_struct_by_name_in_all_files(src_dir, "Model", Some("RegularUserSchema")); - assert!( - result_regular.is_some(), - "RegularUserSchema hint should match regular_user.rs" - ); - let (metadata_regular, _) = result_regular.unwrap(); - assert!( - metadata_regular.definition.contains("name"), - "Should be regular_user Model with name field" - ); - } - - // ============================================================ - // Coverage tests for find_struct_from_schema_path - // ============================================================ - - #[test] - fn test_find_struct_from_schema_path_empty_string() { - // Tests: Empty path string -> returns None - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - let temp_dir = TempDir::new().unwrap(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let result = find_struct_from_schema_path(""); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_none(), "Empty path should return None"); - } - - #[test] - fn test_find_struct_from_schema_path_no_module() { - // Tests: Path with only struct name (no module) -> returns None - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - let temp_dir = TempDir::new().unwrap(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Only "Schema" with no module path - after filtering crate/self/super, module_segments is empty - let result = find_struct_from_schema_path("crate::Schema"); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_none(), "Path with no module should return None"); - } - - #[test] - #[serial] - fn test_find_struct_from_schema_path_with_non_struct_items() { - // Tests: File contains non-struct items that get skipped - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - let content = r" -pub enum NotStruct { A, B } -pub fn not_struct() {} -pub struct Target { pub id: i32 } -pub const NOT_STRUCT: i32 = 1; -"; - std::fs::write(models_dir.join("item.rs"), content).unwrap(); - - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let result = find_struct_from_schema_path("crate::models::item::Target"); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_some(), "Should find Target struct"); - assert!(result.unwrap().definition.contains("Target")); - } - - // ============================================================ - // Coverage tests for find_model_from_schema_path - // ============================================================ - - #[test] - fn test_find_model_from_schema_path_empty_after_filter() { - // Tests: After filtering "Schema" and other keywords, segments is empty - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - let temp_dir = TempDir::new().unwrap(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Only "Schema" - after filtering, empty - let result = find_model_from_schema_path("Schema"); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_none(), "Empty segments should return None"); - } - - #[test] - fn test_find_model_from_schema_path_no_module() { - // Tests: After filtering crate/self/super, module_segments is empty - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - let temp_dir = TempDir::new().unwrap(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // "crate::Schema" - after filtering "Schema" and "crate", module_segments is empty - let result = find_model_from_schema_path("crate::Schema"); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_none(), "No module segments should return None"); - } - - #[test] - #[serial] - fn test_find_model_from_schema_path_success() { - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - let content = "pub struct Model { pub id: i32, pub name: String }"; - std::fs::write(models_dir.join("user.rs"), content).unwrap(); - - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let result = find_model_from_schema_path("crate::models::user::Schema"); +#[cfg(test)] +mod schema_type_lookup_tests { + use std::collections::HashMap; - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } + use quote::quote; + use serial_test::serial; - assert!(result.is_some(), "Should find Model"); - assert!(result.unwrap().definition.contains("Model")); - } + use crate::metadata::StructMetadata; + use crate::schema_macro::{SchemaTypeInput, generate_schema_type_code}; #[test] #[serial] - fn test_find_struct_disambiguation_fallback_contains() { - // Tests: No exact match, but fallback "contains" finds exactly one match - // Tests for fallback contains path - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - std::fs::create_dir(src_dir.join("models")).unwrap(); - // No file named exactly "special.rs", but "special_item.rs" contains "special" - std::fs::write( - src_dir.join("models").join("special_item.rs"), - "pub struct Model { pub special_field: i32 }", - ) - .unwrap(); - // Another file that doesn't match - std::fs::write( - src_dir.join("models").join("regular.rs"), - "pub struct Model { pub regular_field: String }", - ) - .unwrap(); - - // With hint "SpecialSchema" -> prefix "special" - // No exact match (no "special.rs"), but "special_item.rs" contains "special" - let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("SpecialSchema")); - assert!( - result.is_some(), - "SpecialSchema hint should match special_item.rs via contains fallback" - ); - let (metadata, module_path) = result.unwrap(); - assert!( - metadata.definition.contains("special_field"), - "Should be special_item Model with special_field" - ); - assert!( - module_path.contains(&"special_item".to_string()), - "Module path should contain 'special_item'" - ); - } - - // ============================================================ - // Tests for find_fk_column_from_target_entity - // ============================================================ + fn test_generate_schema_type_code_qualified_path_file_lookup_success() { + // Tests: qualified path found via file lookup, module_path used when source is empty + use tempfile::TempDir; - #[test] - #[serial] - fn test_find_fk_column_from_target_entity_success() { - // Tests: Full success path - find FK column from target entity - // Full success path let temp_dir = TempDir::new().unwrap(); let src_dir = temp_dir.path().join("src"); let models_dir = src_dir.join("models"); std::fs::create_dir_all(&models_dir).unwrap(); - // Create notification.rs with a BelongsTo relation that has relation_enum matching via_rel - let notification_model = r#" + // Create user.rs with Model struct + let user_model = r" pub struct Model { pub id: i32, - pub message: String, - pub target_user_id: i32, - #[sea_orm(belongs_to = "super::user::Entity", from = "target_user_id", to = "id", relation_enum = "TargetUser")] - pub target_user: BelongsTo, + pub name: String, + pub email: String, } -"#; - std::fs::write(models_dir.join("notification.rs"), notification_model).unwrap(); +"; + std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + // Save original CARGO_MANIFEST_DIR + let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); + // SAFETY: This is a test that runs single-threaded unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - let result = - find_fk_column_from_target_entity("crate::models::notification::Schema", "TargetUser"); + // Use qualified path - file lookup should succeed + let tokens = quote!(UserSchema from crate::models::user::Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let storage: HashMap = HashMap::new(); // Empty storage - force file lookup + + let result = generate_schema_type_code(&input, &storage); + // Restore CARGO_MANIFEST_DIR + // SAFETY: This is a test that runs single-threaded unsafe { - if let Some(dir) = original { + if let Some(dir) = original_manifest_dir { std::env::set_var("CARGO_MANIFEST_DIR", dir); } else { std::env::remove_var("CARGO_MANIFEST_DIR"); } } - assert_eq!( - result, - Some("target_user_id".to_string()), - "Should find FK column 'target_user_id'" - ); + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("UserSchema")); + assert!(output.contains("id")); + assert!(output.contains("name")); + assert!(output.contains("email")); } #[test] #[serial] - fn test_find_fk_column_from_target_entity_mod_rs() { - // Tests: Find FK column from mod.rs file + fn test_generate_schema_type_code_simple_name_file_lookup_fallback() { + // Tests: simple name (not in storage) found via file lookup with schema_name hint + use tempfile::TempDir; + let temp_dir = TempDir::new().unwrap(); let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models").join("notification"); + let models_dir = src_dir.join("models"); std::fs::create_dir_all(&models_dir).unwrap(); - let notification_model = r#" + // Create user.rs with Model struct + let user_model = r" pub struct Model { pub id: i32, - pub sender_id: i32, - #[sea_orm(belongs_to = "super::super::user::Entity", from = "sender_id", to = "id", relation_enum = "Sender")] - pub sender: BelongsTo, + pub username: String, } -"#; - std::fs::write(models_dir.join("mod.rs"), notification_model).unwrap(); +"; + std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + // Save original CARGO_MANIFEST_DIR + let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); + // SAFETY: This is a test that runs single-threaded unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - let result = - find_fk_column_from_target_entity("crate::models::notification::Schema", "Sender"); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert_eq!( - result, - Some("sender_id".to_string()), - "Should find FK column from mod.rs" - ); - } - - #[test] - #[serial] - fn test_find_fk_column_from_target_entity_empty_module_segments() { - // Tests: Empty module segments return None - let temp_dir = TempDir::new().unwrap(); - - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + // Use simple name with schema_name hint - file lookup should find it via hint + // name = "UserSchema" provides hint to look in user.rs + let tokens = quote!(Schema from Model, name = "UserSchema"); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let storage: HashMap = HashMap::new(); // Empty storage - force file lookup - // After filtering "crate", "Schema", segments is empty - let result = find_fk_column_from_target_entity("crate::Schema", "SomeRelation"); + let result = generate_schema_type_code(&input, &storage); + // Restore CARGO_MANIFEST_DIR + // SAFETY: This is a test that runs single-threaded unsafe { - if let Some(dir) = original { + if let Some(dir) = original_manifest_dir { std::env::set_var("CARGO_MANIFEST_DIR", dir); } else { std::env::remove_var("CARGO_MANIFEST_DIR"); } } - assert!(result.is_none(), "Empty module segments should return None"); + assert!(result.is_ok()); + let (tokens, metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("Schema")); + assert!(output.contains("id")); + assert!(output.contains("username")); + // Metadata should be returned for custom name + assert!(metadata.is_some()); + assert_eq!(metadata.unwrap().name, "UserSchema"); } - #[test] - #[serial] - fn test_find_fk_column_from_target_entity_file_not_found() { - // Tests: File does not exist -> continue, then return None - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - std::fs::create_dir_all(&src_dir).unwrap(); - - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Path to non-existent file - let result = - find_fk_column_from_target_entity("crate::models::nonexistent::Schema", "SomeRelation"); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_none(), "Non-existent file should return None"); - } + // ============================================================ + // Tests for HasMany explicit pick with inline type + // ============================================================ #[test] #[serial] - fn test_find_fk_column_from_target_entity_unparseable_file() { - // Tests: File cannot be parsed -> returns None - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create unparseable file - std::fs::write(models_dir.join("broken.rs"), "this is not valid rust {{{{").unwrap(); - - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let result = - find_fk_column_from_target_entity("crate::models::broken::Schema", "SomeRelation"); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_none(), "Unparseable file should return None"); - } + fn test_generate_schema_type_code_has_many_explicit_pick_inline_type() { + // Tests: HasMany is explicitly picked, inline type is generated + use tempfile::TempDir; - #[test] - #[serial] - fn test_find_fk_column_from_target_entity_no_model_struct() { - // Tests: File exists but has no Model struct let temp_dir = TempDir::new().unwrap(); let src_dir = temp_dir.path().join("src"); let models_dir = src_dir.join("models"); std::fs::create_dir_all(&models_dir).unwrap(); - // Create file without Model struct - let content = r" -pub struct SomethingElse { + // Create memo.rs with Model struct (the target of HasMany) + let memo_model = r" +pub struct Model { pub id: i32, + pub title: String, + pub content: String, } -pub enum Status { Active, Inactive } "; - std::fs::write(models_dir.join("nomodel.rs"), content).unwrap(); - - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + std::fs::write(models_dir.join("memo.rs"), memo_model).unwrap(); - let result = - find_fk_column_from_target_entity("crate::models::nomodel::Schema", "SomeRelation"); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!( - result.is_none(), - "File without Model struct should return None" - ); - } - - #[test] - #[serial] - fn test_find_fk_column_from_target_entity_no_matching_relation_enum() { - // Tests: Model exists but no field matches the via_rel - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create model with different relation_enum - let model = r#" + // Create user.rs with Model struct that has HasMany relation + let user_model = r#" +#[sea_orm(table_name = "users")] pub struct Model { pub id: i32, - pub user_id: i32, - #[sea_orm(belongs_to = "super::user::Entity", from = "user_id", to = "id", relation_enum = "Author")] - pub user: BelongsTo, + pub name: String, + pub memos: HasMany, } "#; - std::fs::write(models_dir.join("comment.rs"), model).unwrap(); + std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + // Save original CARGO_MANIFEST_DIR + let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); + // SAFETY: This is a test that runs single-threaded unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - // Search for "TargetUser" but only "Author" exists - let result = - find_fk_column_from_target_entity("crate::models::comment::Schema", "TargetUser"); + // Explicitly pick HasMany field - should generate inline type + let tokens = + quote!(UserSchema from crate::models::user::Model, pick = ["id", "name", "memos"]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let storage: HashMap = HashMap::new(); + let result = generate_schema_type_code(&input, &storage); + + // Restore CARGO_MANIFEST_DIR + // SAFETY: This is a test that runs single-threaded unsafe { - if let Some(dir) = original { + if let Some(dir) = original_manifest_dir { std::env::set_var("CARGO_MANIFEST_DIR", dir); } else { std::env::remove_var("CARGO_MANIFEST_DIR"); } } - assert!( - result.is_none(), - "Non-matching relation_enum should return None" - ); + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should have inline type definition for memos + assert!(output.contains("UserSchema")); + assert!(output.contains("memos")); + // Inline type should be Vec + assert!(output.contains("Vec <")); } #[test] #[serial] - fn test_find_fk_column_from_target_entity_tuple_struct() { - // Tests: Model is a tuple struct (not named fields) -> skip + fn test_generate_schema_type_code_has_many_explicit_pick_file_not_found() { + // Tests: HasMany is explicitly picked but target file not found - should skip field + use tempfile::TempDir; + let temp_dir = TempDir::new().unwrap(); let src_dir = temp_dir.path().join("src"); let models_dir = src_dir.join("models"); std::fs::create_dir_all(&models_dir).unwrap(); - // Create tuple struct Model - let model = "pub struct Model(i32, String);"; - std::fs::write(models_dir.join("tuple.rs"), model).unwrap(); + // Create user.rs with Model struct that has HasMany to nonexistent model + let user_model = r#" +#[sea_orm(table_name = "users")] +pub struct Model { + pub id: i32, + pub name: String, + pub items: HasMany, +} +"#; + std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + // Save original CARGO_MANIFEST_DIR + let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); + // SAFETY: This is a test that runs single-threaded unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - let result = - find_fk_column_from_target_entity("crate::models::tuple::Schema", "SomeRelation"); + // Explicitly pick HasMany field - file not found, should skip + let tokens = + quote!(UserSchema from crate::models::user::Model, pick = ["id", "name", "items"]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let storage: HashMap = HashMap::new(); + + let result = generate_schema_type_code(&input, &storage); + // Restore CARGO_MANIFEST_DIR + // SAFETY: This is a test that runs single-threaded unsafe { - if let Some(dir) = original { + if let Some(dir) = original_manifest_dir { std::env::set_var("CARGO_MANIFEST_DIR", dir); } else { std::env::remove_var("CARGO_MANIFEST_DIR"); } } - assert!(result.is_none(), "Tuple struct Model should return None"); + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // items field should be skipped (file not found for inline type) + assert!(!output.contains("items")); + // But other fields should exist + assert!(output.contains("id")); + assert!(output.contains("name")); } - #[test] #[serial] - fn test_find_fk_column_from_target_entity_field_no_from_attr() { - // Tests: Field matches relation_enum but has no `from` attribute + fn test_generate_schema_type_code_qualified_path_with_nonempty_module_path() { + // Tests: qualified path with explicit module segments that are not empty + use tempfile::TempDir; + let temp_dir = TempDir::new().unwrap(); let src_dir = temp_dir.path().join("src"); let models_dir = src_dir.join("models"); std::fs::create_dir_all(&models_dir).unwrap(); - // Create model with relation_enum but no `from` attribute - let model = r#" + // Create user.rs + let user_model = r" pub struct Model { pub id: i32, - pub user_id: i32, - #[sea_orm(belongs_to = "super::user::Entity", to = "id", relation_enum = "TargetUser")] - pub user: BelongsTo, + pub name: String, } -"#; - std::fs::write(models_dir.join("nofrom.rs"), model).unwrap(); +"; + std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + // Save original CARGO_MANIFEST_DIR + let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); + // SAFETY: This is a test that runs single-threaded unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - let result = - find_fk_column_from_target_entity("crate::models::nofrom::Schema", "TargetUser"); + // crate::models::user::Model - this is a qualified path + // extract_module_path should return ["crate", "models", "user"] + // So the if source_module_path.is_empty() check should be false + let tokens = quote!(UserSchema from crate::models::user::Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let storage: HashMap = HashMap::new(); + + let result = generate_schema_type_code(&input, &storage); + // Restore CARGO_MANIFEST_DIR + // SAFETY: This is a test that runs single-threaded unsafe { - if let Some(dir) = original { + if let Some(dir) = original_manifest_dir { std::env::set_var("CARGO_MANIFEST_DIR", dir); } else { std::env::remove_var("CARGO_MANIFEST_DIR"); } } - // extract_belongs_to_from_field returns None when no `from` attr - assert!( - result.is_none(), - "Field without 'from' attribute should return None" - ); - } - - // ============================================================ - // Coverage tests for find_struct_by_name_in_all_files (candidate/rest paths) - // ============================================================ - - #[test] - #[serial] - fn test_find_struct_candidate_unparseable_file() { - // Tests line 145: candidate file fails to parse -> continue to next candidate - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - // user.rs matches hint prefix "user" (candidate), contains "Model" text, but won't parse - std::fs::write( - src_dir.join("user.rs"), - "pub struct Model {{{{ broken syntax", - ) - .unwrap(); - - // valid.rs contains Model and parses fine (goes to rest since filename doesn't match prefix) - std::fs::write(src_dir.join("valid.rs"), "pub struct Model { pub id: i32 }").unwrap(); - - let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("UserSchema")); - - assert!( - result.is_some(), - "Should find Model in valid.rs after skipping unparseable candidate user.rs" - ); - } - - #[test] - #[serial] - fn test_find_struct_exact_filename_disambiguation() { - // Tests lines 168-170: multiple candidates found, exact filename match disambiguates - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - // user.rs: exact match (normalize_name("user") == prefix "user") - std::fs::write(src_dir.join("user.rs"), "pub struct Model { pub id: i32 }").unwrap(); - - // user_extended.rs: contains-match only (normalize_name("user_extended") = "userextended" != "user") - std::fs::write( - src_dir.join("user_extended.rs"), - "pub struct Model { pub name: String }", - ) - .unwrap(); - - let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("UserSchema")); - - assert!(result.is_some(), "Should resolve via exact filename match"); - let (metadata, _) = result.unwrap(); - assert!( - metadata.definition.contains("id"), - "Should return user.rs Model (with id field)" - ); - } - - #[test] - #[serial] - fn test_find_struct_no_match_in_candidates_falls_to_rest() { - // Tests line 189: candidates have no struct match -> rs_files = rest -> full scan finds it - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - // user.rs is a candidate (filename matches "user" prefix) but has no struct Model - // Must contain "Model" text for get_struct_candidates to include it - std::fs::write( - src_dir.join("user.rs"), - "pub struct Other { pub x: i32 } // Model ref", - ) - .unwrap(); - - // data.rs is in rest (filename "data" doesn't contain "user"), has struct Model - std::fs::write(src_dir.join("data.rs"), "pub struct Model { pub id: i32 }").unwrap(); - - let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("UserSchema")); - - assert!( - result.is_some(), - "Should find Model in data.rs after candidates had no match" - ); + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("UserSchema")); } #[test] #[serial] - fn test_find_struct_full_scan_unparseable_file() { - // Tests line 197: full-scan file fails to parse -> continue to next file - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - // user.rs is candidate but no struct Model - std::fs::write( - src_dir.join("user.rs"), - "pub struct Other { pub x: i32 } // Model", - ) - .unwrap(); - - // broken.rs is rest, contains "Model" text but won't parse - std::fs::write(src_dir.join("broken.rs"), "Model unparseable {{{{{").unwrap(); - - // valid.rs is rest, has struct Model - std::fs::write(src_dir.join("valid.rs"), "pub struct Model { pub id: i32 }").unwrap(); - - let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("UserSchema")); - - assert!( - result.is_some(), - "Should find Model in valid.rs after skipping unparseable broken.rs in rest" - ); - } + fn test_generate_schema_type_code_cross_module_json_alias_uses_public_path() { + use tempfile::TempDir; - #[test] - #[serial] - fn test_find_struct_from_path_qualified_module_path() { - // Exercises the candidate_file_paths call (line 82) with a fully qualified path - // where the file exists at the expected module location let temp_dir = TempDir::new().unwrap(); let src_dir = temp_dir.path().join("src"); let models_dir = src_dir.join("models"); + let routes_dir = src_dir.join("routes"); std::fs::create_dir_all(&models_dir).unwrap(); + std::fs::create_dir_all(&routes_dir).unwrap(); - // Create user.rs at the expected module path location - std::fs::write( - models_dir.join("user.rs"), - "pub struct Model { pub id: i32, pub name: String }", - ) - .unwrap(); - - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Use a fully qualified path: crate::models::user::Model - // This ensures module_segments = ["models", "user"] (non-empty after filtering "crate") - // which reaches line 82: candidate_file_paths(&src_dir, &module_segments) - let ty: syn::Type = syn::parse_str("crate::models::user::Model").unwrap(); - let result = find_struct_from_path(&ty, None); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!( - result.is_some(), - "Should find Model struct via qualified path" - ); - let (metadata, module_path) = result.unwrap(); - assert!( - metadata.definition.contains("Model"), - "Definition should contain Model" - ); - assert_eq!( - module_path, - vec!["crate", "models", "user"], - "Module path should be inferred from type path" - ); - } + let json_case_model = r#" +use sea_orm::entity::prelude::*; - #[test] - #[serial] - fn test_find_struct_from_path_mod_rs_variant() { - // Exercises candidate_file_paths with the mod.rs pattern - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models").join("user"); - std::fs::create_dir_all(&models_dir).unwrap(); +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "json_case")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub payload: Json, +} - // Create mod.rs instead of user.rs +impl ActiveModelBehavior for ActiveModel {} +"#; + std::fs::write(models_dir.join("json_case.rs"), json_case_model).unwrap(); std::fs::write( - models_dir.join("mod.rs"), - "pub struct Model { pub id: i32, pub email: String }", + routes_dir.join("json_case.rs"), + "vespera::schema_type!(RouteJsonCaseSchema from crate::models::json_case::Model);", ) .unwrap(); - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let ty: syn::Type = syn::parse_str("crate::models::user::Model").unwrap(); - let result = find_struct_from_path(&ty, None); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_some(), "Should find Model struct via mod.rs path"); - let (metadata, _) = result.unwrap(); - assert!( - metadata.definition.contains("email"), - "Should find the correct Model with email field" - ); - } - - #[test] - #[serial] - fn test_find_fk_column_parse_struct_cached_failure() { - // Exercises line 334: get_struct_definition succeeds but parse_struct_cached fails. - // We inject an invalid struct definition string into the cache so that - // parse_struct_cached returns Err, triggering the `continue` branch. - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create a real file so the file_path exists (candidate_file_paths will find it) - let model_file = models_dir.join("item.rs"); - std::fs::write(&model_file, "pub struct Model { pub id: i32 }").unwrap(); - - // Inject a CORRUPT definition for "Model" at this path — syn::parse_str will fail - crate::schema_macro::file_cache::inject_struct_definition_for_test( - &model_file, - "Model", - "not valid rust {{ struct }}", - ); - - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - // This should trigger: get_struct_definition -> Some(corrupt) -> parse_struct_cached -> Err -> continue - let result = - find_fk_column_from_target_entity("crate::models::item::Schema", "SomeRelation"); + let tokens = quote!(RouteJsonCaseSchema from crate::models::json_case::Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let storage: HashMap = HashMap::new(); + let result = generate_schema_type_code(&input, &storage); unsafe { - if let Some(dir) = original { + if let Some(dir) = original_manifest_dir { std::env::set_var("CARGO_MANIFEST_DIR", dir); } else { std::env::remove_var("CARGO_MANIFEST_DIR"); } } - assert!( - result.is_none(), - "Should return None when struct definition fails to parse" - ); + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("pub payload : vespera :: serde_json :: Value")); + assert!(!output.contains("crate :: models :: json_case :: Json")); } } diff --git a/crates/vespera_macro/src/schema_macro/file_lookup/fk.rs b/crates/vespera_macro/src/schema_macro/file_lookup/fk.rs new file mode 100644 index 00000000..7f7d47dd --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/file_lookup/fk.rs @@ -0,0 +1,493 @@ +//! Foreign-key lookup for SeaORM HasMany relations. + +use std::path::Path; + +use super::lookup::candidate_file_paths; + +/// Find the FK column name from the target entity for a `HasMany` relation with `via_rel`. +/// +/// When a `HasMany` relation has `via_rel = "TargetUser"`, this function: +/// 1. Looks up the target entity file (e.g., notification.rs from schema path) +/// 2. Finds the field with matching `relation_enum = "TargetUser"` +/// 3. Extracts and returns the `from` attribute value (e.g., "`target_user_id`") +/// +/// Returns None if the target file can't be found or parsed, or if no matching relation exists. +#[allow(clippy::too_many_lines)] +pub fn find_fk_column_from_target_entity( + target_schema_path: &str, + via_rel: &str, +) -> Option { + use crate::schema_macro::seaorm::{extract_belongs_to_from_field, extract_relation_enum}; + + // Get CARGO_MANIFEST_DIR to locate src folder (cached to avoid repeated syscalls) + let manifest_dir = crate::schema_macro::file_cache::get_manifest_dir()?; + let src_dir = Path::new(&manifest_dir).join("src"); + + // Parse the schema path to get file path + // e.g., "crate :: models :: notification :: Schema" -> src/models/notification.rs + let segments: Vec<&str> = target_schema_path + .split("::") + .map(str::trim) + .filter(|s| !s.is_empty() && *s != "Schema" && *s != "Entity") + .collect(); + + let module_segments: Vec<&str> = segments + .iter() + .filter(|s| **s != "crate" && **s != "self" && **s != "super") + .copied() + .collect(); + + if module_segments.is_empty() { + return None; + } + + // Try different file path patterns + let file_paths = candidate_file_paths(&src_dir, &module_segments); + + for file_path in file_paths { + if !file_path.exists() { + continue; + } + + let Some(model_def) = + crate::schema_macro::file_cache::get_struct_definition(&file_path, "Model") + else { + continue; + }; + let Ok(model) = crate::schema_macro::file_cache::parse_struct_cached(&model_def) else { + continue; + }; + + // Search through fields for the one with matching relation_enum + if let syn::Fields::Named(fields_named) = &model.fields { + for field in &fields_named.named { + let field_relation_enum = extract_relation_enum(&field.attrs); + if field_relation_enum.as_deref() == Some(via_rel) { + // Found the matching field, extract FK column from `from` attribute + return extract_belongs_to_from_field(&field.attrs); + } + } + } + } + + None +} + +#[cfg(test)] +mod tests { + use crate::schema_macro::file_lookup::{ + find_struct_by_name_in_all_files, find_struct_from_path, + }; + + use super::*; + use serial_test::serial; + use tempfile::TempDir; + #[test] + #[serial] + fn test_find_fk_column_from_target_entity_success() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + let notification_model = r#" +pub struct Model { + pub id: i32, + pub message: String, + pub target_user_id: i32, + #[sea_orm(belongs_to = "super::user::Entity", from = "target_user_id", to = "id", relation_enum = "TargetUser")] + pub target_user: BelongsTo, +} +"#; + std::fs::write(models_dir.join("notification.rs"), notification_model).unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = + find_fk_column_from_target_entity("crate::models::notification::Schema", "TargetUser"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert_eq!( + result, + Some("target_user_id".to_string()), + "Should find FK column 'target_user_id'" + ); + } + #[test] + #[serial] + fn test_find_fk_column_from_target_entity_mod_rs() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models").join("notification"); + std::fs::create_dir_all(&models_dir).unwrap(); + let notification_model = r#" +pub struct Model { + pub id: i32, + pub sender_id: i32, + #[sea_orm(belongs_to = "super::super::user::Entity", from = "sender_id", to = "id", relation_enum = "Sender")] + pub sender: BelongsTo, +} +"#; + std::fs::write(models_dir.join("mod.rs"), notification_model).unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = + find_fk_column_from_target_entity("crate::models::notification::Schema", "Sender"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert_eq!( + result, + Some("sender_id".to_string()), + "Should find FK column from mod.rs" + ); + } + #[test] + #[serial] + fn test_find_fk_column_from_target_entity_empty_module_segments() { + let temp_dir = TempDir::new().unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = find_fk_column_from_target_entity("crate::Schema", "SomeRelation"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_none(), "Empty module segments should return None"); + } + #[test] + #[serial] + fn test_find_fk_column_from_target_entity_file_not_found() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + std::fs::create_dir_all(&src_dir).unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = + find_fk_column_from_target_entity("crate::models::nonexistent::Schema", "SomeRelation"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_none(), "Non-existent file should return None"); + } + #[test] + #[serial] + fn test_find_fk_column_from_target_entity_unparseable_file() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + std::fs::write(models_dir.join("broken.rs"), "this is not valid rust {{{{").unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = + find_fk_column_from_target_entity("crate::models::broken::Schema", "SomeRelation"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_none(), "Unparseable file should return None"); + } + #[test] + #[serial] + fn test_find_fk_column_from_target_entity_no_model_struct() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + let content = r" +pub struct SomethingElse { + pub id: i32, +} +pub enum Status { Active, Inactive } +"; + std::fs::write(models_dir.join("nomodel.rs"), content).unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = + find_fk_column_from_target_entity("crate::models::nomodel::Schema", "SomeRelation"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!( + result.is_none(), + "File without Model struct should return None" + ); + } + #[test] + #[serial] + fn test_find_fk_column_from_target_entity_no_matching_relation_enum() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + let model = r#" +pub struct Model { + pub id: i32, + pub user_id: i32, + #[sea_orm(belongs_to = "super::user::Entity", from = "user_id", to = "id", relation_enum = "Author")] + pub user: BelongsTo, +} +"#; + std::fs::write(models_dir.join("comment.rs"), model).unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = + find_fk_column_from_target_entity("crate::models::comment::Schema", "TargetUser"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!( + result.is_none(), + "Non-matching relation_enum should return None" + ); + } + #[test] + #[serial] + fn test_find_fk_column_from_target_entity_tuple_struct() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + let model = "pub struct Model(i32, String);"; + std::fs::write(models_dir.join("tuple.rs"), model).unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = + find_fk_column_from_target_entity("crate::models::tuple::Schema", "SomeRelation"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_none(), "Tuple struct Model should return None"); + } + #[test] + #[serial] + fn test_find_fk_column_from_target_entity_field_no_from_attr() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + let model = r#" +pub struct Model { + pub id: i32, + pub user_id: i32, + #[sea_orm(belongs_to = "super::user::Entity", to = "id", relation_enum = "TargetUser")] + pub user: BelongsTo, +} +"#; + std::fs::write(models_dir.join("nofrom.rs"), model).unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = + find_fk_column_from_target_entity("crate::models::nofrom::Schema", "TargetUser"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!( + result.is_none(), + "Field without 'from' attribute should return None" + ); + } + #[test] + #[serial] + fn test_find_struct_candidate_unparseable_file() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::write( + src_dir.join("user.rs"), + "pub struct Model {{{{ broken syntax", + ) + .unwrap(); + std::fs::write(src_dir.join("valid.rs"), "pub struct Model { pub id: i32 }").unwrap(); + let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("UserSchema")); + assert!( + result.is_some(), + "Should find Model in valid.rs after skipping unparseable candidate user.rs" + ); + } + #[test] + #[serial] + fn test_find_struct_exact_filename_disambiguation() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::write(src_dir.join("user.rs"), "pub struct Model { pub id: i32 }").unwrap(); + std::fs::write( + src_dir.join("user_extended.rs"), + "pub struct Model { pub name: String }", + ) + .unwrap(); + let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("UserSchema")); + assert!(result.is_some(), "Should resolve via exact filename match"); + let (metadata, _) = result.unwrap(); + assert!( + metadata.definition.contains("id"), + "Should return user.rs Model (with id field)" + ); + } + #[test] + #[serial] + fn test_find_struct_no_match_in_candidates_falls_to_rest() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::write( + src_dir.join("user.rs"), + "pub struct Other { pub x: i32 } // Model ref", + ) + .unwrap(); + std::fs::write(src_dir.join("data.rs"), "pub struct Model { pub id: i32 }").unwrap(); + let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("UserSchema")); + assert!( + result.is_some(), + "Should find Model in data.rs after candidates had no match" + ); + } + #[test] + #[serial] + fn test_find_struct_full_scan_unparseable_file() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::write( + src_dir.join("user.rs"), + "pub struct Other { pub x: i32 } // Model", + ) + .unwrap(); + std::fs::write(src_dir.join("broken.rs"), "Model unparseable {{{{{").unwrap(); + std::fs::write(src_dir.join("valid.rs"), "pub struct Model { pub id: i32 }").unwrap(); + let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("UserSchema")); + assert!( + result.is_some(), + "Should find Model in valid.rs after skipping unparseable broken.rs in rest" + ); + } + #[test] + #[serial] + fn test_find_struct_from_path_qualified_module_path() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + std::fs::write( + models_dir.join("user.rs"), + "pub struct Model { pub id: i32, pub name: String }", + ) + .unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let ty: syn::Type = syn::parse_str("crate::models::user::Model").unwrap(); + let result = find_struct_from_path(&ty, None); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!( + result.is_some(), + "Should find Model struct via qualified path" + ); + let (metadata, module_path) = result.unwrap(); + assert!( + metadata.definition.contains("Model"), + "Definition should contain Model" + ); + assert_eq!( + module_path, + vec!["crate", "models", "user"], + "Module path should be inferred from type path" + ); + } + #[test] + #[serial] + fn test_find_struct_from_path_mod_rs_variant() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models").join("user"); + std::fs::create_dir_all(&models_dir).unwrap(); + std::fs::write( + models_dir.join("mod.rs"), + "pub struct Model { pub id: i32, pub email: String }", + ) + .unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let ty: syn::Type = syn::parse_str("crate::models::user::Model").unwrap(); + let result = find_struct_from_path(&ty, None); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_some(), "Should find Model struct via mod.rs path"); + let (metadata, _) = result.unwrap(); + assert!( + metadata.definition.contains("email"), + "Should find the correct Model with email field" + ); + } + #[test] + #[serial] + fn test_find_fk_column_parse_struct_cached_failure() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + let model_file = models_dir.join("item.rs"); + std::fs::write(&model_file, "pub struct Model { pub id: i32 }").unwrap(); + crate::schema_macro::file_cache::inject_struct_definition_for_test( + &model_file, + "Model", + "not valid rust {{ struct }}", + ); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = + find_fk_column_from_target_entity("crate::models::item::Schema", "SomeRelation"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!( + result.is_none(), + "Should return None when struct definition fails to parse" + ); + } +} diff --git a/crates/vespera_macro/src/schema_macro/file_lookup/lookup.rs b/crates/vespera_macro/src/schema_macro/file_lookup/lookup.rs new file mode 100644 index 00000000..cf25591b --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/file_lookup/lookup.rs @@ -0,0 +1,879 @@ +//! Struct lookup/search helpers. + +use std::path::{Path, PathBuf}; + +use syn::Type; + +use crate::metadata::StructMetadata; + +/// Build candidate file paths from module segments. +/// +/// Given a source directory and module segments (e.g., `["models", "memo"]`), +/// returns both `{src_dir}/models/memo.rs` and `{src_dir}/models/memo/mod.rs`. +#[inline] +pub(super) fn candidate_file_paths(src_dir: &Path, module_segments: &[&str]) -> [PathBuf; 2] { + let joined = module_segments.join("/"); + [ + src_dir.join(format!("{joined}.rs")), + src_dir.join(format!("{joined}/mod.rs")), + ] +} + +/// Try to find a struct definition from a module path by reading source files. +/// +/// This allows `schema_type`! to work with structs defined in other files, like: +/// ```ignore +/// // In src/routes/memos.rs +/// schema_type!(CreateMemoRequest from models::memo::Model, pick = ["title", "content"]); +/// ``` +/// +/// The function will: +/// 1. Parse the path (e.g., `models::memo::Model` or `crate::models::memo::Model`) +/// 2. Convert to file path (e.g., `src/models/memo.rs`) +/// 3. Read and parse the file to find the struct definition +/// +/// For simple names (e.g., just `Model` without module path), it will scan all `.rs` +/// files in `src/` to find the struct. This supports same-file usage like: +/// ```ignore +/// pub struct Model { ... } +/// vespera::schema_type!(Schema from Model, name = "UserSchema"); +/// ``` +/// +/// The `schema_name_hint` is used to disambiguate when multiple structs with the same +/// name exist. For example, with `name = "UserSchema"`, it will prefer `user.rs`. +/// +/// Returns `(StructMetadata, Vec)` where the Vec is the module path. +/// For qualified paths, this is extracted from the type itself. +/// For simple names, it's inferred from the file location. +pub fn find_struct_from_path( + ty: &Type, + schema_name_hint: Option<&str>, +) -> Option<(StructMetadata, Vec)> { + // Get CARGO_MANIFEST_DIR to locate src folder (cached to avoid repeated syscalls) + let manifest_dir = crate::schema_macro::file_cache::get_manifest_dir()?; + let src_dir = Path::new(&manifest_dir).join("src"); + + // Extract path segments from the type + let Type::Path(type_path) = ty else { + return None; + }; + + let segments: Vec = type_path + .path + .segments + .iter() + .map(|s| s.ident.to_string()) + .collect(); + + if segments.is_empty() { + return None; + } + + // The last segment is the struct name + let struct_name = segments.last()?.clone(); + + // Build possible file paths from the module path + // e.g., models::memo::Model -> src/models/memo.rs or src/models/memo/mod.rs + // e.g., crate::models::memo::Model -> src/models/memo.rs + let module_segments: Vec<&str> = segments[..segments.len() - 1] + .iter() + .filter(|s| *s != "crate" && *s != "self" && *s != "super") + .map(std::string::String::as_str) + .collect(); + + // If no module path (simple name like `Model`), scan all files with schema_name hint + if module_segments.is_empty() { + return find_struct_by_name_in_all_files(&src_dir, &struct_name, schema_name_hint); + } + + // For qualified paths, the module path is extracted from the type itself + // e.g., crate::models::memo::Model -> ["crate", "models", "memo"] + let type_module_path: Vec = segments[..segments.len() - 1].to_vec(); + + // Try different file path patterns + let file_paths = candidate_file_paths(&src_dir, &module_segments); + + for file_path in file_paths { + if !file_path.exists() { + continue; + } + if let Some(definition) = + crate::schema_macro::file_cache::get_struct_definition(&file_path, &struct_name) + { + return Some(( + StructMetadata::new_model(struct_name, definition), + type_module_path, + )); + } + } + + None +} + +/// Find a struct by name by scanning all `.rs` files in the src directory. +/// +/// This is used as a fallback when the type path doesn't include module information +/// (e.g., just `Model` instead of `crate::models::user::Model`). +/// +/// Resolution strategy: +/// 1. If exactly one struct with the name exists -> use it +/// 2. If multiple exist and `schema_name_hint` is provided (e.g., "UserSchema"): +/// -> Prefer file whose name contains the hint prefix (e.g., "user.rs" for "`UserSchema`") +/// 3. Otherwise -> return None (ambiguous) +/// +/// The `schema_name_hint` is the custom schema name (e.g., "`UserSchema`", "`MemoSchema`") +/// which often contains a hint about the module name. +/// +/// Returns `(StructMetadata, Vec)` where the Vec is the inferred module path +/// from the file location (e.g., `["crate", "models", "user"]`). +#[allow(clippy::too_many_lines)] +pub fn find_struct_by_name_in_all_files( + src_dir: &Path, + struct_name: &str, + schema_name_hint: Option<&str>, +) -> Option<(StructMetadata, Vec)> { + // Use cached struct-candidate index: files already filtered by text + // search. `Arc<[PathBuf]>` — iterate by reference; only matched + // paths are cloned. + let all_files = crate::schema_macro::file_cache::get_struct_candidates(src_dir, struct_name); + let mut rs_files: Vec<&std::path::PathBuf> = all_files.iter().collect(); + + // Pre-compute hint prefix once (used in fast path and fallback disambiguation) + let prefix_normalized = schema_name_hint.map(derive_hint_prefix); + + // FAST PATH: If schema_name_hint is provided, try matching files first. + // This avoids parsing ALL files for the common same-file pattern: + // schema_type!(Schema from Model, name = "UserSchema") in user.rs + if let Some(prefix_normalized) = &prefix_normalized { + // Partition files: candidate files (filename matches hint prefix) vs rest + let (candidates, rest): (Vec<_>, Vec<_>) = rs_files.into_iter().partition(|path| { + path.file_stem() + .and_then(|s| s.to_str()) + .is_some_and(|name| { + let norm = normalize_name(name); + norm == *prefix_normalized || norm.contains(prefix_normalized.as_str()) + }) + }); + + // Parse only candidate files first + let mut found_in_candidates: Vec<(std::path::PathBuf, StructMetadata)> = Vec::new(); + for file_path in &candidates { + if let Some(definition) = + crate::schema_macro::file_cache::get_struct_definition(file_path, struct_name) + { + found_in_candidates.push(( + (*file_path).clone(), + StructMetadata::new_model(struct_name.to_string(), definition), + )); + } + } + + // If exactly one match in candidates, return immediately (fast path hit!) + if found_in_candidates.len() == 1 { + let (path, metadata) = found_in_candidates.remove(0); + let module_path = file_path_to_module_path(&path, src_dir); + return Some((metadata, module_path)); + } + + // If candidates found multiple, try disambiguation by exact filename match + if found_in_candidates.len() > 1 { + let exact_match: Vec<_> = found_in_candidates + .iter() + .filter(|(path, _)| { + path.file_stem() + .and_then(|s| s.to_str()) + .is_some_and(|name| normalize_name(name) == *prefix_normalized) + }) + .collect(); + + if exact_match.len() == 1 { + let (path, metadata) = exact_match[0]; + let module_path = file_path_to_module_path(path, src_dir); + return Some((metadata.clone(), module_path)); + } + + // Still ambiguous among candidates + return None; + } + + // No match in candidates — fall through to scan remaining files + rs_files = rest; + } + + // FULL SCAN: Parse all remaining files (or all files if no hint) + let mut found_structs: Vec<(std::path::PathBuf, StructMetadata)> = Vec::new(); + + for file_path in rs_files { + if let Some(definition) = + crate::schema_macro::file_cache::get_struct_definition(file_path, struct_name) + { + found_structs.push(( + file_path.clone(), + StructMetadata::new_model(struct_name.to_string(), definition), + )); + } + } + + match found_structs.len() { + 1 => { + let (path, metadata) = found_structs.remove(0); + let module_path = file_path_to_module_path(&path, src_dir); + Some((metadata, module_path)) + } + _ => None, + } +} + +/// Derive a normalized prefix from a schema name hint for file matching. +/// +/// Strips common suffixes ("Schema", "Response", "Request") and normalizes +/// by removing underscores and lowercasing. +/// +/// # Examples +/// - "UserSchema" → "user" +/// - "MemoResponse" → "memo" +/// - "AdminUserSchema" → "adminuser" +fn derive_hint_prefix(hint: &str) -> String { + let hint_lower = hint.to_lowercase(); + let prefix = hint_lower + .strip_suffix("schema") + .or_else(|| hint_lower.strip_suffix("response")) + .or_else(|| hint_lower.strip_suffix("request")) + .unwrap_or(&hint_lower); + normalize_name(prefix) +} + +/// Normalize a name by lowercasing and removing underscores in a single pass. +/// Replaces the two-allocation `s.to_lowercase().replace('_', "")` pattern. +#[inline] +fn normalize_name(s: &str) -> String { + s.chars() + .filter(|&c| c != '_') + .map(|c| c.to_ascii_lowercase()) + .collect() +} + +/// Recursively collect all `.rs` files in a directory. +pub fn collect_rs_files_recursive(dir: &Path, files: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_rs_files_recursive(&path, files); + } else if path.extension().is_some_and(|ext| ext == "rs") { + files.push(path); + } + } +} + +/// Derive module path from a file path relative to src directory. +/// +/// Examples: +/// - `src/models/user.rs` -> `["crate", "models", "user"]` +/// - `src/models/user/mod.rs` -> `["crate", "models", "user"]` +/// - `src/lib.rs` -> `["crate"]` +pub fn file_path_to_module_path(file_path: &Path, src_dir: &Path) -> Vec { + let Ok(relative) = file_path.strip_prefix(src_dir) else { + return vec!["crate".to_string()]; + }; + + let mut segments = vec!["crate".to_string()]; + + for component in relative.components() { + if let std::path::Component::Normal(os_str) = component + && let Some(s) = os_str.to_str() + { + // Handle .rs extension + if let Some(name) = s.strip_suffix(".rs") { + // Skip mod.rs and lib.rs - they don't add a segment + if name != "mod" && name != "lib" { + segments.push(name.to_string()); + } + } else { + // Directory name + segments.push(s.to_string()); + } + } + } + + segments +} + +/// Find struct definition from a schema path string (e.g., "`crate::models::user::Schema`"). +/// +/// Similar to `find_struct_from_path` but takes a string path instead of `syn::Type`. +pub fn find_struct_from_schema_path(path_str: &str) -> Option { + // Get CARGO_MANIFEST_DIR to locate src folder (cached to avoid repeated syscalls) + let manifest_dir = crate::schema_macro::file_cache::get_manifest_dir()?; + let src_dir = Path::new(&manifest_dir).join("src"); + + // Parse the path string into segments + let segments: Vec<&str> = path_str.split("::").filter(|s| !s.is_empty()).collect(); + + if segments.is_empty() { + return None; + } + + // The last segment is the struct name + let struct_name = segments.last()?.to_string(); + + // Build possible file paths from the module path + // e.g., crate::models::user::Schema -> src/models/user.rs + let module_segments: Vec<&str> = segments[..segments.len() - 1] + .iter() + .filter(|s| **s != "crate" && **s != "self" && **s != "super") + .copied() + .collect(); + + if module_segments.is_empty() { + return None; + } + + // Try different file path patterns + let file_paths = candidate_file_paths(&src_dir, &module_segments); + + for file_path in file_paths { + if !file_path.exists() { + continue; + } + if let Some(definition) = + crate::schema_macro::file_cache::get_struct_definition(&file_path, &struct_name) + { + return Some(StructMetadata::new_model(struct_name, definition)); + } + } + + None +} + +/// Find the Model definition from a Schema path. +/// Converts "`crate::models::user::Schema`" -> finds Model in src/models/user.rs +#[allow(clippy::too_many_lines)] +pub fn find_model_from_schema_path(schema_path_str: &str) -> Option { + // Get CARGO_MANIFEST_DIR to locate src folder (cached to avoid repeated syscalls) + let manifest_dir = crate::schema_macro::file_cache::get_manifest_dir()?; + let src_dir = Path::new(&manifest_dir).join("src"); + + // Parse the path string and convert Schema path to module path + // e.g., "crate :: models :: user :: Schema" -> ["crate", "models", "user"] + let segments: Vec<&str> = schema_path_str + .split("::") + .map(str::trim) + .filter(|s| !s.is_empty() && *s != "Schema") + .collect(); + + if segments.is_empty() { + return None; + } + + // Build possible file paths from the module path + let module_segments: Vec<&str> = segments + .iter() + .filter(|s| **s != "crate" && **s != "self" && **s != "super") + .copied() + .collect(); + + if module_segments.is_empty() { + return None; + } + + // Try different file path patterns + let file_paths = candidate_file_paths(&src_dir, &module_segments); + + for file_path in file_paths { + if !file_path.exists() { + continue; + } + if let Some(definition) = + crate::schema_macro::file_cache::get_struct_definition(&file_path, "Model") + { + return Some(StructMetadata::new_model("Model".to_string(), definition)); + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::path::Path; + use tempfile::TempDir; + #[test] + fn test_file_path_to_module_path_simple() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + let file_path = src_dir.join("models").join("user.rs"); + let result = file_path_to_module_path(&file_path, src_dir); + assert_eq!(result, vec!["crate", "models", "user"]); + } + #[test] + fn test_file_path_to_module_path_mod_rs() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + let file_path = src_dir.join("models").join("mod.rs"); + let result = file_path_to_module_path(&file_path, src_dir); + assert_eq!(result, vec!["crate", "models"]); + } + #[test] + fn test_file_path_to_module_path_lib_rs() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + let file_path = src_dir.join("lib.rs"); + let result = file_path_to_module_path(&file_path, src_dir); + assert_eq!(result, vec!["crate"]); + } + #[test] + fn test_file_path_to_module_path_not_under_src() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let file_path = temp_dir.path().join("other").join("file.rs"); + let result = file_path_to_module_path(&file_path, &src_dir); + assert_eq!(result, vec!["crate"]); + } + #[test] + fn test_collect_rs_files_recursive_empty_dir() { + let temp_dir = TempDir::new().unwrap(); + let mut files = Vec::new(); + collect_rs_files_recursive(temp_dir.path(), &mut files); + assert!(files.is_empty()); + } + #[test] + fn test_collect_rs_files_recursive_nonexistent_dir() { + let mut files = Vec::new(); + collect_rs_files_recursive(Path::new("/nonexistent/path"), &mut files); + assert!(files.is_empty()); + } + #[test] + fn test_collect_rs_files_recursive_with_files() { + let temp_dir = TempDir::new().unwrap(); + std::fs::write(temp_dir.path().join("main.rs"), "fn main() {}").unwrap(); + std::fs::create_dir(temp_dir.path().join("models")).unwrap(); + std::fs::write( + temp_dir.path().join("models").join("user.rs"), + "struct User;", + ) + .unwrap(); + std::fs::write(temp_dir.path().join("other.txt"), "not a rust file").unwrap(); + let mut files = Vec::new(); + collect_rs_files_recursive(temp_dir.path(), &mut files); + assert_eq!(files.len(), 2); + assert!(files.iter().all(|f| f.extension().unwrap() == "rs")); + } + #[test] + #[serial] + fn test_find_struct_from_path_non_path_type() { + use syn::Type; + let ty: Type = syn::parse_str("&str").unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + let temp_dir = TempDir::new().unwrap(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = find_struct_from_path(&ty, None); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_none(), "Non-path type should return None"); + } + #[test] + #[serial] + fn test_find_struct_from_path_empty_segments() { + use syn::{Path, TypePath}; + let empty_path = Path { + leading_colon: None, + segments: syn::punctuated::Punctuated::new(), + }; + let ty = Type::Path(TypePath { + qself: None, + path: empty_path, + }); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + let temp_dir = TempDir::new().unwrap(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = find_struct_from_path(&ty, None); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_none(), "Empty segments should return None"); + } + #[test] + #[serial] + fn test_find_struct_from_path_file_with_non_matching_items() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + let content = r" +pub enum SomeEnum { A, B } +pub fn some_function() {} +pub const SOME_CONST: i32 = 42; +pub trait SomeTrait {} +pub struct NotTarget { pub x: i32 } +pub struct Target { pub id: i32 } +"; + std::fs::write(models_dir.join("mixed.rs"), content).unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let ty: Type = syn::parse_str("crate::models::mixed::Target").unwrap(); + let result = find_struct_from_path(&ty, None); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_some(), "Should find Target struct"); + let (metadata, _) = result.unwrap(); + assert!(metadata.definition.contains("Target")); + } + #[test] + #[serial] + fn test_find_struct_by_name_unreadable_file() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::write( + src_dir.join("valid.rs"), + "pub struct Target { pub id: i32 }", + ) + .unwrap(); + let broken = src_dir.join("broken.rs"); + let nonexistent = src_dir.join("nonexistent"); + #[cfg(unix)] + let _ = std::os::unix::fs::symlink(&nonexistent, &broken); + #[cfg(windows)] + let _ = std::os::windows::fs::symlink_file(&nonexistent, &broken); + let result = find_struct_by_name_in_all_files(src_dir, "Target", None); + assert!( + result.is_some(), + "Should find Target, skipping broken symlink" + ); + } + #[test] + #[serial] + fn test_find_struct_by_name_unparseable_file() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::write(src_dir.join("broken.rs"), "this is not valid rust {{{{").unwrap(); + std::fs::write( + src_dir.join("valid.rs"), + "pub struct Target { pub id: i32 }", + ) + .unwrap(); + let result = find_struct_by_name_in_all_files(src_dir, "Target", None); + assert!( + result.is_some(), + "Should find Target in valid file, skipping broken" + ); + } + #[test] + #[serial] + fn test_find_struct_disambiguation_with_hint() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::create_dir(src_dir.join("models")).unwrap(); + std::fs::write( + src_dir.join("models").join("user.rs"), + "pub struct Model { pub id: i32, pub name: String }", + ) + .unwrap(); + std::fs::write( + src_dir.join("models").join("memo.rs"), + "pub struct Model { pub id: i32, pub title: String }", + ) + .unwrap(); + let result_no_hint = find_struct_by_name_in_all_files(src_dir, "Model", None); + assert!( + result_no_hint.is_none(), + "Without hint, multiple Models should be ambiguous" + ); + let result_with_hint = + find_struct_by_name_in_all_files(src_dir, "Model", Some("UserSchema")); + assert!( + result_with_hint.is_some(), + "With UserSchema hint, should find user.rs" + ); + let (metadata, module_path) = result_with_hint.unwrap(); + assert!( + metadata.definition.contains("name"), + "Should be user Model with name field" + ); + assert!( + module_path.contains(&"user".to_string()), + "Module path should contain 'user'" + ); + let result_memo = find_struct_by_name_in_all_files(src_dir, "Model", Some("MemoSchema")); + assert!( + result_memo.is_some(), + "With MemoSchema hint, should find memo.rs" + ); + let (metadata_memo, _) = result_memo.unwrap(); + assert!( + metadata_memo.definition.contains("title"), + "Should be memo Model with title field" + ); + } + #[test] + #[serial] + fn test_find_struct_disambiguation_with_response_suffix() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::create_dir(src_dir.join("models")).unwrap(); + std::fs::write( + src_dir.join("models").join("user.rs"), + "pub struct Data { pub id: i32 }", + ) + .unwrap(); + std::fs::write( + src_dir.join("models").join("item.rs"), + "pub struct Data { pub name: String }", + ) + .unwrap(); + let result = find_struct_by_name_in_all_files(src_dir, "Data", Some("UserResponse")); + assert!( + result.is_some(), + "With UserResponse hint, should find user.rs" + ); + } + #[test] + #[serial] + fn test_find_struct_disambiguation_with_request_suffix() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::create_dir(src_dir.join("models")).unwrap(); + std::fs::write( + src_dir.join("models").join("user.rs"), + "pub struct Input { pub id: i32 }", + ) + .unwrap(); + std::fs::write( + src_dir.join("models").join("item.rs"), + "pub struct Input { pub name: String }", + ) + .unwrap(); + let result = find_struct_by_name_in_all_files(src_dir, "Input", Some("UserRequest")); + assert!( + result.is_some(), + "With UserRequest hint, should find user.rs" + ); + } + #[test] + #[serial] + fn test_find_struct_disambiguation_still_ambiguous() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::create_dir(src_dir.join("models")).unwrap(); + std::fs::write( + src_dir.join("models").join("user_admin.rs"), + "pub struct Model { pub id: i32 }", + ) + .unwrap(); + std::fs::write( + src_dir.join("models").join("user_regular.rs"), + "pub struct Model { pub name: String }", + ) + .unwrap(); + let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("UserSchema")); + assert!( + result.is_none(), + "Multiple files matching hint should still be ambiguous" + ); + } + #[test] + #[serial] + fn test_find_struct_disambiguation_snake_case_filename() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::create_dir(src_dir.join("models")).unwrap(); + std::fs::write( + src_dir.join("models").join("admin_user.rs"), + "pub struct Model { pub id: i32, pub role: String }", + ) + .unwrap(); + std::fs::write( + src_dir.join("models").join("regular_user.rs"), + "pub struct Model { pub id: i32, pub name: String }", + ) + .unwrap(); + let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("AdminUserSchema")); + assert!( + result.is_some(), + "AdminUserSchema hint should match admin_user.rs" + ); + let (metadata, module_path) = result.unwrap(); + assert!( + metadata.definition.contains("role"), + "Should be admin_user Model with role field" + ); + assert!( + module_path.contains(&"admin_user".to_string()), + "Module path should contain 'admin_user'" + ); + let result_regular = + find_struct_by_name_in_all_files(src_dir, "Model", Some("RegularUserSchema")); + assert!( + result_regular.is_some(), + "RegularUserSchema hint should match regular_user.rs" + ); + let (metadata_regular, _) = result_regular.unwrap(); + assert!( + metadata_regular.definition.contains("name"), + "Should be regular_user Model with name field" + ); + } + #[test] + #[serial] + fn test_find_struct_from_schema_path_empty_string() { + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + let temp_dir = TempDir::new().unwrap(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = find_struct_from_schema_path(""); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_none(), "Empty path should return None"); + } + #[test] + #[serial] + fn test_find_struct_from_schema_path_no_module() { + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + let temp_dir = TempDir::new().unwrap(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = find_struct_from_schema_path("crate::Schema"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_none(), "Path with no module should return None"); + } + #[test] + #[serial] + fn test_find_struct_from_schema_path_with_non_struct_items() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + let content = r" +pub enum NotStruct { A, B } +pub fn not_struct() {} +pub struct Target { pub id: i32 } +pub const NOT_STRUCT: i32 = 1; +"; + std::fs::write(models_dir.join("item.rs"), content).unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = find_struct_from_schema_path("crate::models::item::Target"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_some(), "Should find Target struct"); + assert!(result.unwrap().definition.contains("Target")); + } + #[test] + #[serial] + fn test_find_model_from_schema_path_empty_after_filter() { + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + let temp_dir = TempDir::new().unwrap(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = find_model_from_schema_path("Schema"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_none(), "Empty segments should return None"); + } + #[test] + #[serial] + fn test_find_model_from_schema_path_no_module() { + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + let temp_dir = TempDir::new().unwrap(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = find_model_from_schema_path("crate::Schema"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_none(), "No module segments should return None"); + } + #[test] + #[serial] + fn test_find_model_from_schema_path_success() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + let content = "pub struct Model { pub id: i32, pub name: String }"; + std::fs::write(models_dir.join("user.rs"), content).unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = find_model_from_schema_path("crate::models::user::Schema"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_some(), "Should find Model"); + assert!(result.unwrap().definition.contains("Model")); + } + #[test] + #[serial] + fn test_find_struct_disambiguation_fallback_contains() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::create_dir(src_dir.join("models")).unwrap(); + std::fs::write( + src_dir.join("models").join("special_item.rs"), + "pub struct Model { pub special_field: i32 }", + ) + .unwrap(); + std::fs::write( + src_dir.join("models").join("regular.rs"), + "pub struct Model { pub regular_field: String }", + ) + .unwrap(); + let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("SpecialSchema")); + assert!( + result.is_some(), + "SpecialSchema hint should match special_item.rs via contains fallback" + ); + let (metadata, module_path) = result.unwrap(); + assert!( + metadata.definition.contains("special_field"), + "Should be special_item Model with special_field" + ); + assert!( + module_path.contains(&"special_item".to_string()), + "Module path should contain 'special_item'" + ); + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model.rs b/crates/vespera_macro/src/schema_macro/from_model.rs index 96c104f7..fbe8d396 100644 --- a/crates/vespera_macro/src/schema_macro/from_model.rs +++ b/crates/vespera_macro/src/schema_macro/from_model.rs @@ -2,24 +2,15 @@ //! //! Generates async `from_model` implementations for `SeaORM` models with relations. -use std::collections::HashMap; - -use super::type_utils::normalize_token_str; use proc_macro2::TokenStream; use quote::quote; -use syn::Type; -use super::{ - circular::{generate_inline_struct_construction, generate_inline_type_construction}, - file_cache::{get_circular_analysis, get_fk_column, get_struct_from_schema_path}, - seaorm::RelationFieldInfo, - type_utils::snake_to_pascal_case, -}; -use crate::metadata::StructMetadata; +mod generate; + +pub use generate::generate_from_model_with_relations; /// Build Entity path from Schema path. /// e.g., `crate::models::user::Schema` -> `crate::models::user::Entity` -#[allow(clippy::too_many_lines, clippy::option_if_let_else)] pub fn build_entity_path_from_schema_path( schema_path: &TokenStream, _source_module_path: &[String], @@ -38,2474 +29,32 @@ pub fn build_entity_path_from_schema_path( quote! { #(#path_idents)::* } } -/// Generate `from_model` impl for `SeaORM` Model WITH relations (async version). -/// -/// When circular references are detected, generates inline struct construction -/// that excludes circular fields (sets them to default values). -/// -/// ```ignore -/// impl NewType { -/// pub async fn from_model( -/// model: SourceType, -/// db: &sea_orm::DatabaseConnection, -/// ) -> Result { -/// // Load related entities -/// let user = model.find_related(user::Entity).one(db).await?; -/// let tags = model.find_related(tag::Entity).all(db).await?; -/// -/// Ok(Self { -/// id: model.id, -/// // Inline construction with circular field defaulted: -/// user: user.map(|r| Box::new(user::Schema { id: r.id, memos: vec![], ... })), -/// tags: tags.into_iter().map(|r| tag::Schema { ... }).collect(), -/// }) -/// } -/// } -/// ``` -#[allow(clippy::too_many_lines, clippy::option_if_let_else)] -pub fn generate_from_model_with_relations( - new_type_name: &syn::Ident, - source_type: &Type, - field_mappings: &[(syn::Ident, syn::Ident, bool, bool)], - relation_fields: &[RelationFieldInfo], - source_module_path: &[String], - _schema_storage: &HashMap, -) -> TokenStream { - // Build relation loading statements - let relation_loads: Vec = relation_fields - .iter() - .map(|rel| { - let field_name = &rel.field_name; - let entity_path = build_entity_path_from_schema_path(&rel.schema_path, source_module_path); - - match rel.relation_type.as_str() { - "HasOne" | "BelongsTo" => { - // When relation_enum is specified, use the specific Relation variant - // This handles cases where multiple relations point to the same Entity type - if let Some(ref relation_enum_name) = rel.relation_enum { - let relation_variant = syn::Ident::new(relation_enum_name, proc_macro2::Span::call_site()); - - if rel.is_optional { - // Optional FK: load only if FK value exists - if let Some(ref fk_col) = rel.fk_column { - let fk_ident = syn::Ident::new(fk_col, proc_macro2::Span::call_site()); - quote! { - let #field_name = match &model.#fk_ident { - Some(fk_value) => #entity_path::find_by_id(fk_value.clone()).one(db).await?, - None => None, - }; - } - } else { - // Fallback: use find_related with Relation enum - quote! { - let #field_name = Entity::find_related(Relation::#relation_variant) - .filter(::PrimaryKey::eq(&model)) - .one(db) - .await?; - } - } - } else { - // Required FK: directly query by FK value - if let Some(ref fk_col) = rel.fk_column { - let fk_ident = syn::Ident::new(fk_col, proc_macro2::Span::call_site()); - quote! { - let #field_name = #entity_path::find_by_id(model.#fk_ident.clone()).one(db).await?; - } - } else { - // Fallback: use find_related with Relation enum - quote! { - let #field_name = Entity::find_related(Relation::#relation_variant) - .filter(::PrimaryKey::eq(&model)) - .one(db) - .await?; - } - } - } - } else { - // Standard case: single relation to target entity, use find_related - quote! { - let #field_name = model.find_related(#entity_path).one(db).await?; - } - } - } - "HasMany" => { - // Try via_rel first, fall back to relation_enum as FK source - let fk_rel_source = rel.via_rel.as_ref().or(rel.relation_enum.as_ref()); - if let Some(via_rel_value) = fk_rel_source { - let schema_path_str = normalize_token_str(&rel.schema_path); - if let Some(fk_col_name) = get_fk_column(&schema_path_str, via_rel_value) { - let fk_col_pascal = snake_to_pascal_case(&fk_col_name); - let fk_col_ident = syn::Ident::new(&fk_col_pascal, proc_macro2::Span::call_site()); - - let entity_path_str = normalize_token_str(&entity_path); - let column_path_str = entity_path_str.replace(":: Entity", ":: Column"); - let column_path_idents: Vec = column_path_str - .split("::") - .filter_map(|s| { - let trimmed = s.trim(); - if trimmed.is_empty() { None } else { Some(syn::Ident::new(trimmed, proc_macro2::Span::call_site())) } - }) - .collect(); - - quote! { - let #field_name = #(#column_path_idents)::*::#fk_col_ident - .into_column() - .eq(model.id.clone()) - .into_condition(); - let #field_name = #entity_path::find() - .filter(#field_name) - .all(db) - .await?; - } - } else { - quote! { - // WARNING: Could not find FK column for relation, using empty vec - let #field_name: Vec<_> = vec![]; - } - } - } else { - // Standard HasMany - use find_related - quote! { - let #field_name = model.find_related(#entity_path).all(db).await?; - } - } - } - _ => quote! {}, - } - }) - .collect(); - - // Check if we need a parent stub for HasMany relations with required circular back-refs - // This is needed when: UserSchema.memos has MemoSchema which has required user: Box - // BUT: If the relation uses an inline type (which excludes circular fields), we don't need a parent stub - let needs_parent_stub = relation_fields.iter().any(|rel| { - if rel.relation_type != "HasMany" { - return false; - } - // If using inline type, circular fields are excluded, so no parent stub needed - if rel.inline_type_info.is_some() { - return false; - } - let schema_path_str = normalize_token_str(&rel.schema_path); - let model_path_str = schema_path_str.replace("::Schema", "::Model"); - let related_model = get_struct_from_schema_path(&model_path_str); - - if let Some(ref model) = related_model { - let analysis = get_circular_analysis(source_module_path, &model.definition); - // Check if any circular field is a required relation - analysis.circular_fields.iter().any(|cf| { - analysis - .circular_field_required - .get(cf) - .copied() - .unwrap_or(false) - }) - } else { - false - } - }); - - // Generate parent stub field assignments (non-relation fields from model) - let parent_stub_fields: Vec = if needs_parent_stub { - field_mappings - .iter() - .map(|(new_ident, source_ident, _wrapped, is_relation)| { - if *is_relation { - // For relation fields in stub, use defaults - if let Some(rel) = relation_fields - .iter() - .find(|r| &r.field_name == source_ident) - { - match rel.relation_type.as_str() { - "HasMany" => quote! { #new_ident: vec![] }, - _ if rel.is_optional => quote! { #new_ident: None }, - // Required single relations in parent stub - this shouldn't happen - // as we're creating stub to break circular ref - _ => quote! { #new_ident: None }, - } - } else { - quote! { #new_ident: Default::default() } - } - } else { - // Regular field - clone from model - quote! { #new_ident: model.#source_ident.clone() } - } - }) - .collect() - } else { - vec![] - }; - - // Pre-build relation lookup for O(1) access in field assignments loop - let relation_by_name: HashMap<&syn::Ident, &RelationFieldInfo> = relation_fields - .iter() - .map(|rel| (&rel.field_name, rel)) - .collect(); - - // Build field assignments - // For relation fields, check for circular references and use inline construction if needed - let field_assignments: Vec = field_mappings - .iter() - .map(|(new_ident, source_ident, wrapped, is_relation)| { - if *is_relation { - // Find the relation info for this field - if let Some(rel) = relation_by_name.get(source_ident) { - let schema_path = &rel.schema_path; - - // Try to find the related MODEL definition to check for circular refs - // The schema_path is like "crate::models::user::Schema", but the actual - // struct is "Model" in the same module. We need to look up the Model - // to see if it has relations pointing back to us. - let schema_path_str = normalize_token_str(schema_path); - - // Convert schema path to model path: Schema -> Model - let model_path_str = schema_path_str.replace("::Schema", "::Model"); - - // Try to find the related Model definition from file - let related_model_from_file = get_struct_from_schema_path(&model_path_str); - - // Get the definition string - let related_def_str = related_model_from_file.as_ref().map_or("", |s| s.definition.as_str()); - - // Analyze circular references, FK relations, and FK optionality in ONE pass - let analysis = get_circular_analysis(source_module_path, related_def_str); - let circular_fields = &analysis.circular_fields; - let has_circular = !circular_fields.is_empty(); - - // Check if we have inline type info - if so, use the inline type - // instead of the original schema path - if let Some((ref inline_type_name, ref included_fields)) = rel.inline_type_info { - // Use inline type construction - let inline_construct = generate_inline_type_construction(inline_type_name, included_fields, related_def_str, "r"); - - match rel.relation_type.as_str() { - "HasOne" | "BelongsTo" => { - if rel.is_optional { - quote! { - #new_ident: #source_ident.map(|r| Box::new(#inline_construct)) - } - } else { - quote! { - #new_ident: Box::new({ - let r = #source_ident.ok_or_else(|| sea_orm::DbErr::RecordNotFound( - format!("Required relation '{}' not found", stringify!(#source_ident)) - ))?; - #inline_construct - }) - } - } - } - "HasMany" => { - quote! { - #new_ident: #source_ident.into_iter().map(|r| #inline_construct).collect() - } - } - _ => quote! { #new_ident: Default::default() }, - } - } else { - // No inline type - use original behavior - match rel.relation_type.as_str() { - "HasOne" | "BelongsTo" => { - if has_circular { - // Use inline construction to break circular ref - let inline_construct = generate_inline_struct_construction(schema_path, related_def_str, circular_fields, "r"); - if rel.is_optional { - quote! { - #new_ident: #source_ident.map(|r| Box::new(#inline_construct)) - } - } else { - quote! { - #new_ident: Box::new({ - let r = #source_ident.ok_or_else(|| sea_orm::DbErr::RecordNotFound( - format!("Required relation '{}' not found", stringify!(#source_ident)) - ))?; - #inline_construct - }) - } - } - } else { - // No circular ref - use has_fk_relations from the analysis - let target_has_fk = analysis.has_fk_relations; - - if target_has_fk { - // Target schema has FK relations -> use async from_model() - if rel.is_optional { - quote! { - #new_ident: match #source_ident { - Some(r) => Some(Box::new(#schema_path::from_model(r, db).await?)), - None => None, - } - } - } else { - quote! { - #new_ident: Box::new(#schema_path::from_model( - #source_ident.ok_or_else(|| sea_orm::DbErr::RecordNotFound( - format!("Required relation '{}' not found", stringify!(#source_ident)) - ))?, - db, - ).await?) - } - } - } else { - // Target schema has no FK relations -> use sync From::from() - if rel.is_optional { - quote! { - #new_ident: #source_ident.map(|r| Box::new(<#schema_path as From<_>>::from(r))) - } - } else { - quote! { - #new_ident: Box::new(<#schema_path as From<_>>::from( - #source_ident.ok_or_else(|| sea_orm::DbErr::RecordNotFound( - format!("Required relation '{}' not found", stringify!(#source_ident)) - ))? - )) - } - } - } - } - } - "HasMany" => { - // HasMany is excluded by default, so this branch is only hit - // when explicitly picked. Use inline construction (no relations). - if has_circular { - // Use inline construction to break circular ref - let inline_construct = generate_inline_struct_construction(schema_path, related_def_str, circular_fields, "r"); - quote! { - #new_ident: #source_ident.into_iter().map(|r| #inline_construct).collect() - } - } else { - // No circular ref - use has_fk_relations from the analysis - let target_has_fk = analysis.has_fk_relations; - - if target_has_fk { - // Target has FK relations but HasMany doesn't load nested data anyway, - // so we use inline construction (flat fields only) - let inline_construct = generate_inline_struct_construction( - schema_path, - related_def_str, - &[], // no circular fields to exclude - "r", - ); - quote! { - #new_ident: #source_ident.into_iter().map(|r| #inline_construct).collect() - } - } else { - quote! { - #new_ident: #source_ident.into_iter().map(|r| <#schema_path as From<_>>::from(r)).collect() - } - } - } - } - _ => quote! { #new_ident: Default::default() }, - } - } - } else { - quote! { #new_ident: Default::default() } - } - } else if *wrapped { - quote! { #new_ident: Some(model.#source_ident) } - } else { - quote! { #new_ident: model.#source_ident } - } - }) - .collect(); - - // Circular references are now handled automatically via inline construction - // For HasMany with required circular back-refs, we create a parent stub first - - // Generate parent stub definition if needed - let parent_stub_def = if needs_parent_stub { - quote! { - let __parent_stub__ = Self { - #(#parent_stub_fields),* - }; - } - } else { - quote! {} - }; - - quote! { - impl #new_type_name { - pub async fn from_model( - model: #source_type, - db: &sea_orm::DatabaseConnection, - ) -> Result { - use sea_orm::ModelTrait; - - #(#relation_loads)* - - #parent_stub_def - - Ok(Self { - #(#field_assignments),* - }) - } - } - } -} - #[cfg(test)] mod tests { - use serial_test::serial; + use rstest::rstest; use super::*; - #[test] - fn test_build_entity_path_from_schema_path() { - let schema_path = quote! { crate::models::user::Schema }; - let result = build_entity_path_from_schema_path(&schema_path, &[]); - let output = result.to_string(); - assert!(output.contains("crate")); - assert!(output.contains("models")); - assert!(output.contains("user")); - assert!(output.contains("Entity")); - assert!(!output.contains("Schema")); - } - - #[test] - fn test_build_entity_path_simple() { - let schema_path = quote! { user::Schema }; - let result = build_entity_path_from_schema_path(&schema_path, &[]); - let output = result.to_string(); - assert!(output.contains("user")); - assert!(output.contains("Entity")); - } - - #[test] - fn test_build_entity_path_deeply_nested() { - let schema_path = quote! { crate::api::models::entities::user::Schema }; - let result = build_entity_path_from_schema_path(&schema_path, &[]); - let output = result.to_string(); - assert!(output.contains("api")); - assert!(output.contains("models")); - assert!(output.contains("entities")); - assert!(output.contains("user")); - assert!(output.contains("Entity")); - assert!(!output.contains("Schema")); - } - - #[test] - fn test_build_entity_path_single_segment() { - let schema_path = quote! { Schema }; - let result = build_entity_path_from_schema_path(&schema_path, &[]); - let output = result.to_string(); - assert!(output.contains("Entity")); - } - - // Tests for generate_from_model_with_relations - - fn create_test_relation_info( - field_name: &str, - relation_type: &str, - schema_path: TokenStream, - is_optional: bool, - ) -> RelationFieldInfo { - RelationFieldInfo { - field_name: syn::Ident::new(field_name, proc_macro2::Span::call_site()), - relation_type: relation_type.to_string(), - schema_path, - is_optional, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - } - } - - #[test] - fn test_generate_from_model_with_required_relation() { - let new_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("user", proc_macro2::Span::call_site()), - syn::Ident::new("user", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - // Required relation (is_optional = false) - let relation_fields = vec![create_test_relation_info( - "user", - "HasOne", - quote! { user::Schema }, - false, - )]; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - assert!(output.contains("impl MemoSchema")); - // Required relations should have RecordNotFound error handling - assert!(output.contains("DbErr :: RecordNotFound")); - } - - #[test] - fn test_generate_from_model_with_wrapped_fields() { - let new_type_name = syn::Ident::new("TestSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - // Field with wrapped=true means it needs Some() wrapping - let field_mappings = vec![( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - true, // wrapped - false, - )]; - let relation_fields = vec![]; - let source_module_path = vec!["crate".to_string()]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - assert!(output.contains("Some (model . id)")); - } - - #[test] - fn test_generate_from_model_with_has_one_optional() { - let new_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("user", proc_macro2::Span::call_site()), - syn::Ident::new("user", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - let relation_fields = vec![create_test_relation_info( - "user", - "HasOne", - quote! { user::Schema }, - true, - )]; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - assert!(output.contains("impl MemoSchema")); - assert!(output.contains("pub async fn from_model")); - // quote! produces spaced output like "sea_orm :: DatabaseConnection" - assert!(output.contains("sea_orm :: DatabaseConnection")); - assert!(output.contains("Result < Self , sea_orm :: DbErr >")); - assert!(output.contains("find_related")); - assert!(output.contains(". one (db)")); - } - - #[test] - fn test_generate_from_model_with_has_many() { - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("memos", proc_macro2::Span::call_site()), - syn::Ident::new("memos", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - let relation_fields = vec![create_test_relation_info( - "memos", - "HasMany", - quote! { memo::Schema }, - false, - )]; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - assert!(output.contains("impl UserSchema")); - assert!(output.contains("pub async fn from_model")); - assert!(output.contains(". all (db)")); - } - - #[test] - fn test_generate_from_model_with_belongs_to() { - let new_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("user", proc_macro2::Span::call_site()), - syn::Ident::new("user", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - let relation_fields = vec![create_test_relation_info( - "user", - "BelongsTo", - quote! { user::Schema }, - true, - )]; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - assert!(output.contains("impl MemoSchema")); - assert!(output.contains("find_related")); - assert!(output.contains(". one (db)")); - } - - #[test] - fn test_generate_from_model_no_relations() { - let new_type_name = syn::Ident::new("SimpleSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("name", proc_macro2::Span::call_site()), - syn::Ident::new("name", proc_macro2::Span::call_site()), - false, - false, - ), - ]; - let relation_fields = vec![]; - let source_module_path = vec!["crate".to_string()]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - assert!(output.contains("impl SimpleSchema")); - assert!(output.contains("id : model . id")); - assert!(output.contains("name : model . name")); - } - - #[test] - fn test_generate_from_model_with_inline_type() { - let new_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("user", proc_macro2::Span::call_site()), - syn::Ident::new("user", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - // Relation with inline type info (for circular references) - let mut rel_info = - create_test_relation_info("user", "HasOne", quote! { user::Schema }, true); - rel_info.inline_type_info = Some(( - syn::Ident::new("MemoSchema_User", proc_macro2::Span::call_site()), - vec!["id".to_string(), "name".to_string()], - )); - let relation_fields = vec![rel_info]; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - assert!(output.contains("impl MemoSchema")); - assert!(output.contains("find_related")); - } - - #[test] - fn test_generate_from_model_unknown_relation_type() { - let new_type_name = syn::Ident::new("TestSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("unknown", proc_macro2::Span::call_site()), - syn::Ident::new("unknown", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - // Unknown relation type - let relation_fields = vec![create_test_relation_info( - "unknown", - "UnknownType", - quote! { some::Schema }, - true, - )]; - let source_module_path = vec!["crate".to_string()]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - // Unknown relation type should generate empty token (no load statement) - assert!(output.contains("impl TestSchema")); - } - - #[test] - fn test_generate_from_model_relation_field_not_in_mappings() { - let new_type_name = syn::Ident::new("TestSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - // Relation field with different source_ident - ( - syn::Ident::new("owner", proc_macro2::Span::call_site()), - syn::Ident::new("different_name", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - let relation_fields = vec![create_test_relation_info( - "user", - "HasOne", - quote! { user::Schema }, - true, - )]; - let source_module_path = vec!["crate".to_string()]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - // Should still generate valid code - assert!(output.contains("impl TestSchema")); - } - - #[test] - fn test_generate_from_model_with_has_many_inline() { - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("memos", proc_macro2::Span::call_site()), - syn::Ident::new("memos", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - // HasMany with inline type - let mut rel_info = - create_test_relation_info("memos", "HasMany", quote! { memo::Schema }, false); - rel_info.inline_type_info = Some(( - syn::Ident::new("UserSchema_Memos", proc_macro2::Span::call_site()), - vec!["id".to_string(), "title".to_string()], - )); - let relation_fields = vec![rel_info]; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - assert!(output.contains("impl UserSchema")); - assert!(output.contains(". all (db)")); - assert!(output.contains("into_iter")); - assert!(output.contains("collect")); - } - - // ============================================================ - // Coverage tests for file-based lookup branches - // ============================================================ - - #[test] - #[serial] - fn test_generate_from_model_needs_parent_stub_with_required_circular() { - // Tests for from_model generation - // Tests: HasMany relation where target model has REQUIRED circular back-ref - // This triggers needs_parent_stub = true and generates parent stub fields - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create memo.rs with Model that has REQUIRED circular back-ref to user - // The memo has `user: Box` (not Option) - required - let memo_model = r#" -pub struct Model { - pub id: i32, - pub title: String, - pub user_id: i32, - #[sea_orm(belongs_to = "super::user::Entity", from = "user_id")] - pub user: BelongsTo, -} -"#; - std::fs::write(models_dir.join("memo.rs"), memo_model).unwrap(); - - // Create user.rs - let user_model = r" -pub struct Model { - pub id: i32, - pub name: String, -} -"; - std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - - // Save and set CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::user::Model").unwrap(); - - // Field mappings: id (regular), name (regular), memos (relation, HasMany) - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("name", proc_macro2::Span::call_site()), - syn::Ident::new("name", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("memos", proc_macro2::Span::call_site()), - syn::Ident::new("memos", proc_macro2::Span::call_site()), - false, - true, // is_relation - ), - ]; - - // HasMany WITHOUT inline_type_info (triggers parent stub path) - let relation_fields = vec![create_test_relation_info( - "memos", - "HasMany", - quote! { crate::models::memo::Schema }, - false, - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - // Restore CARGO_MANIFEST_DIR - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl UserSchema")); - assert!(output.contains("from_model")); - // Should have parent stub with __parent_stub__ - assert!( - output.contains("__parent_stub__"), - "Should have parent stub: {output}" - ); - } - - #[test] - #[serial] - fn test_generate_from_model_circular_has_one_optional() { - // Tests for field name resolution - // Tests: HasOne with circular reference, optional - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create profile.rs with circular back-ref to user - let profile_model = r" -pub struct Model { - pub id: i32, - pub bio: String, - pub user: BelongsTo, -} -"; - std::fs::write(models_dir.join("profile.rs"), profile_model).unwrap(); - - // Save and set CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::user::Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("profile", proc_macro2::Span::call_site()), - syn::Ident::new("profile", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // HasOne, optional, WITHOUT inline_type_info - let relation_fields = vec![create_test_relation_info( - "profile", - "HasOne", - quote! { crate::models::profile::Schema }, - true, // optional - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - // Restore CARGO_MANIFEST_DIR - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl UserSchema")); - // Circular optional should have .map(|r| Box::new(...)) - assert!( - output.contains(". map (| r |"), - "Should have map for optional: {output}" - ); - } - - #[test] - #[serial] - fn test_generate_from_model_circular_has_one_required() { - // Tests for relation conversion failure - // Tests: HasOne with circular reference, required - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create profile.rs with circular back-ref to user - let profile_model = r" -pub struct Model { - pub id: i32, - pub bio: String, - pub user: BelongsTo, -} -"; - std::fs::write(models_dir.join("profile.rs"), profile_model).unwrap(); - - // Save and set CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::user::Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("profile", proc_macro2::Span::call_site()), - syn::Ident::new("profile", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // HasOne, REQUIRED, WITHOUT inline_type_info - let relation_fields = vec![create_test_relation_info( - "profile", - "HasOne", - quote! { crate::models::profile::Schema }, - false, // required - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - // Restore CARGO_MANIFEST_DIR - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl UserSchema")); - // Required circular should have Box::new with error handling - assert!( - output.contains("Box :: new"), - "Should have Box::new for required: {output}" - ); - assert!( - output.contains("ok_or_else"), - "Should have ok_or_else: {output}" - ); - } - - #[test] - fn test_generate_from_model_unknown_relation_with_inline_type() { - // Tests for unknown relation type handling - // Tests: Unknown relation type WITH inline_type_info -> Default::default() - let new_type_name = syn::Ident::new("TestSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("weird", proc_macro2::Span::call_site()), - syn::Ident::new("weird", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // Unknown relation type WITH inline_type_info - let mut rel_info = create_test_relation_info( - "weird", - "UnknownRelationType", - quote! { some::Schema }, - true, - ); - rel_info.inline_type_info = Some(( - syn::Ident::new("TestSchema_Weird", proc_macro2::Span::call_site()), - vec!["id".to_string()], - )); - - let relation_fields = vec![rel_info]; - let source_module_path = vec!["crate".to_string()]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - let output = tokens.to_string(); - assert!(output.contains("impl TestSchema")); - // Unknown relation with inline type should use Default::default() - assert!( - output.contains("Default :: default ()"), - "Should have Default::default(): {output}" - ); - } - - #[test] - #[serial] - fn test_generate_from_model_non_circular_has_one_with_fk_optional() { - // Tests for field rename handling - // Tests: HasOne with FK relations in target, no circular, optional - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create address.rs with FK relations but NO circular back-ref to user - let address_model = r" -pub struct Model { - pub id: i32, - pub street: String, - pub city_id: i32, - pub city: BelongsTo, -} -"; - std::fs::write(models_dir.join("address.rs"), address_model).unwrap(); - - // Save and set CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::user::Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("address", proc_macro2::Span::call_site()), - syn::Ident::new("address", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // HasOne, optional, no inline_type_info - let relation_fields = vec![create_test_relation_info( - "address", - "HasOne", - quote! { crate::models::address::Schema }, - true, // optional - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - // Restore CARGO_MANIFEST_DIR - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl UserSchema")); - // Non-circular with FK, optional should have match statement with async from_model - assert!( - output.contains("from_model (r , db) . await"), - "Should have async from_model: {output}" - ); - } - - #[test] - #[serial] - fn test_generate_from_model_non_circular_has_one_with_fk_required() { - // Tests for parent stub generation - // Tests: HasOne with FK relations in target, no circular, required - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create address.rs with FK relations but NO circular back-ref to user - let address_model = r" -pub struct Model { - pub id: i32, - pub street: String, - pub city_id: i32, - pub city: BelongsTo, -} -"; - std::fs::write(models_dir.join("address.rs"), address_model).unwrap(); - - // Save and set CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::user::Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("address", proc_macro2::Span::call_site()), - syn::Ident::new("address", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // HasOne, REQUIRED, no inline_type_info - let relation_fields = vec![create_test_relation_info( - "address", - "HasOne", - quote! { crate::models::address::Schema }, - false, // required - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - // Restore CARGO_MANIFEST_DIR - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl UserSchema")); - // Required with FK should have Box::new with from_model call - assert!( - output.contains("Box :: new"), - "Should have Box::new: {output}" - ); - assert!( - output.contains("from_model"), - "Should have from_model: {output}" - ); - assert!( - output.contains("ok_or_else"), - "Should have ok_or_else: {output}" - ); - } - - #[test] - #[serial] - fn test_generate_from_model_has_many_with_circular() { - // Tests for quote generation - // Tests: HasMany with circular reference - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create memo.rs with circular back-ref to user - let memo_model = r" -pub struct Model { - pub id: i32, - pub title: String, - pub user: BelongsTo, -} -"; - std::fs::write(models_dir.join("memo.rs"), memo_model).unwrap(); - - // Save and set CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::user::Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("memos", proc_macro2::Span::call_site()), - syn::Ident::new("memos", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // HasMany WITHOUT inline_type_info - will use generate_inline_struct_construction - let relation_fields = vec![create_test_relation_info( - "memos", - "HasMany", - quote! { crate::models::memo::Schema }, - false, - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - // Restore CARGO_MANIFEST_DIR - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl UserSchema")); - // HasMany with circular should have into_iter().map().collect() - assert!( - output.contains("into_iter ()"), - "Should have into_iter: {output}" - ); - assert!(output.contains(". map (| r |"), "Should have map: {output}"); - assert!(output.contains("collect"), "Should have collect: {output}"); - } - - #[test] - #[serial] - fn test_generate_from_model_has_many_with_fk_no_circular() { - // Tests for multi-variant case handling - // Tests: HasMany with FK relations in target, no circular - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create tag.rs with FK relations but NO circular back-ref to user - let tag_model = r" -pub struct Model { - pub id: i32, - pub name: String, - pub category_id: i32, - pub category: BelongsTo, -} -"; - std::fs::write(models_dir.join("tag.rs"), tag_model).unwrap(); - - // Save and set CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::user::Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("tags", proc_macro2::Span::call_site()), - syn::Ident::new("tags", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // HasMany, no inline_type_info - let relation_fields = vec![create_test_relation_info( - "tags", - "HasMany", - quote! { crate::models::tag::Schema }, - false, - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - // Restore CARGO_MANIFEST_DIR - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl UserSchema")); - // HasMany with FK but no circular should use inline_struct_construction - assert!( - output.contains("into_iter ()"), - "Should have into_iter: {output}" - ); - assert!(output.contains(". map (| r |"), "Should have map: {output}"); - assert!(output.contains("collect"), "Should have collect: {output}"); - } - - #[test] - #[serial] - fn test_generate_from_model_inline_type_required() { - // Tests: inline_type_info with required BelongsTo - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - let user_model = r" -pub struct Model { - pub id: i32, - pub name: String, -} -"; - std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - - // Save and set CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::memo::Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("user", proc_macro2::Span::call_site()), - syn::Ident::new("user", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // BelongsTo with inline_type_info, REQUIRED - let mut rel_info = create_test_relation_info( - "user", - "BelongsTo", - quote! { crate::models::user::Schema }, - false, // required - ); - rel_info.inline_type_info = Some(( - syn::Ident::new("MemoSchema_User", proc_macro2::Span::call_site()), - vec!["id".to_string(), "name".to_string()], - )); - - let relation_fields = vec![rel_info]; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - // Restore CARGO_MANIFEST_DIR - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl MemoSchema")); - // Required inline type should have Box::new with ok_or_else - assert!( - output.contains("Box :: new"), - "Should have Box::new: {output}" - ); - assert!( - output.contains("ok_or_else"), - "Should have ok_or_else: {output}" - ); - } - - #[test] - #[serial] - fn test_generate_from_model_parent_stub_all_relation_types() { - // Tests for relation type variants - // Tests: Parent stub generation with: - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create memo.rs with REQUIRED circular back-ref to user - // This triggers needs_parent_stub = true - let memo_model = r#" -pub struct Model { - pub id: i32, - pub title: String, - pub user_id: i32, - #[sea_orm(belongs_to = "super::user::Entity", from = "user_id")] - pub user: BelongsTo, -} -"#; - std::fs::write(models_dir.join("memo.rs"), memo_model).unwrap(); - - // Create profile.rs (for optional single relation) - let profile_model = r" -pub struct Model { - pub id: i32, - pub bio: String, -} -"; - std::fs::write(models_dir.join("profile.rs"), profile_model).unwrap(); - - // Create settings.rs (for required single relation) - let settings_model = r" -pub struct Model { - pub id: i32, - pub theme: String, -} -"; - std::fs::write(models_dir.join("settings.rs"), settings_model).unwrap(); - - // Save and set CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::user::Model").unwrap(); - - // Field mappings with various relation types - let field_mappings = vec![ - // Regular field - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - // HasMany - this one triggers needs_parent_stub - ( - syn::Ident::new("memos", proc_macro2::Span::call_site()), - syn::Ident::new("memos", proc_macro2::Span::call_site()), - false, - true, - ), - // Optional single relation - ( - syn::Ident::new("profile", proc_macro2::Span::call_site()), - syn::Ident::new("profile", proc_macro2::Span::call_site()), - false, - true, - ), - // Required single relation - ( - syn::Ident::new("settings", proc_macro2::Span::call_site()), - syn::Ident::new("settings", proc_macro2::Span::call_site()), - false, - true, - ), - // Relation field NOT in relation_fields - ( - syn::Ident::new("orphan_rel", proc_macro2::Span::call_site()), - syn::Ident::new("orphan_rel", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // Relation fields - note: orphan_rel is NOT included here - let relation_fields = vec![ - // HasMany without inline_type_info (triggers needs_parent_stub) - create_test_relation_info( - "memos", - "HasMany", - quote! { crate::models::memo::Schema }, - false, - ), - // Optional HasOne - create_test_relation_info( - "profile", - "HasOne", - quote! { crate::models::profile::Schema }, - true, // optional - ), - // Required BelongsTo - create_test_relation_info( - "settings", - "BelongsTo", - quote! { crate::models::settings::Schema }, - false, // required - ), - // Note: orphan_rel is NOT in relation_fields - ]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - // Restore CARGO_MANIFEST_DIR - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl UserSchema")); - // Should have parent stub - assert!( - output.contains("__parent_stub__"), - "Should have parent stub: {output}" - ); - // Parent stub should have various default values - // Line 113: memos: vec![] - assert!( - output.contains("memos : vec ! []"), - "Should have memos: vec![]: {output}" - ); - // Line 114 & 117: profile/settings: None (both optional and required single relations) - // (Both produce None in parent stub) - assert!( - output.contains("profile : None") || output.contains("settings : None"), - "Should have None for single relations: {output}" - ); - // Line 120: orphan_rel: Default::default() - assert!( - output.contains("Default :: default ()"), - "Should have Default::default() for orphan: {output}" - ); - } - - // ============================================================ - // Tests for relation_enum + fk_column branches - // ============================================================ - - fn create_test_relation_info_full( - field_name: &str, - relation_type: &str, - schema_path: TokenStream, - is_optional: bool, - relation_enum: Option, - fk_column: Option, - via_rel: Option, - ) -> RelationFieldInfo { - RelationFieldInfo { - field_name: syn::Ident::new(field_name, proc_macro2::Span::call_site()), - relation_type: relation_type.to_string(), - schema_path, - is_optional, - inline_type_info: None, - relation_enum, - fk_column, - via_rel, - } - } - - #[test] - fn test_generate_from_model_has_one_with_relation_enum_optional_with_fk() { - // Tests for field name comparison - // Tests: HasOne with relation_enum + optional + fk_column present - let new_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("target_user", proc_macro2::Span::call_site()), - syn::Ident::new("target_user", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // HasOne with relation_enum, optional, WITH fk_column - let relation_fields = vec![create_test_relation_info_full( - "target_user", - "HasOne", - quote! { user::Schema }, - true, // optional - Some("TargetUser".to_string()), // relation_enum - Some("target_user_id".to_string()), // fk_column - None, // via_rel - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - assert!(output.contains("impl MemoSchema")); - // Should have match statement checking FK field - assert!( - output.contains("match & model . target_user_id"), - "Should match on FK field: {output}" - ); - assert!( - output.contains("Some (fk_value)"), - "Should have Some(fk_value) arm: {output}" - ); - assert!( - output.contains("find_by_id"), - "Should use find_by_id: {output}" - ); - } - - #[test] - fn test_generate_from_model_has_one_with_relation_enum_optional_no_fk() { - // Tests for None branch - // Tests: HasOne with relation_enum + optional + NO fk_column (fallback) - let new_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("author", proc_macro2::Span::call_site()), - syn::Ident::new("author", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // HasOne with relation_enum, optional, WITHOUT fk_column - let relation_fields = vec![create_test_relation_info_full( - "author", - "HasOne", - quote! { user::Schema }, - true, // optional - Some("Author".to_string()), // relation_enum - None, // NO fk_column - None, // via_rel - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - assert!(output.contains("impl MemoSchema")); - // Fallback: use Entity::find_related(Relation::Variant) - assert!( - output.contains("Entity :: find_related (Relation :: Author)"), - "Should use find_related with Relation enum: {output}" - ); - } - - #[test] - fn test_generate_from_model_belongs_to_with_relation_enum_required_with_fk() { - // Tests for required relation field - // Tests: BelongsTo with relation_enum + required + fk_column present - let new_type_name = syn::Ident::new("CommentSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("post", proc_macro2::Span::call_site()), - syn::Ident::new("post", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // BelongsTo with relation_enum, required, WITH fk_column - let relation_fields = vec![create_test_relation_info_full( - "post", - "BelongsTo", - quote! { post::Schema }, - false, // required - Some("Post".to_string()), // relation_enum - Some("post_id".to_string()), // fk_column - None, // via_rel - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "comment".to_string(), - ]; - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - assert!(output.contains("impl CommentSchema")); - // Should directly query by FK value - assert!( - output.contains("find_by_id (model . post_id . clone ())"), - "Should use find_by_id with FK: {output}" - ); - } - - #[test] - fn test_generate_from_model_belongs_to_with_relation_enum_required_no_fk() { - // Tests for skip condition - // Tests: BelongsTo with relation_enum + required + NO fk_column (fallback) - let new_type_name = syn::Ident::new("CommentSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("author", proc_macro2::Span::call_site()), - syn::Ident::new("author", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // BelongsTo with relation_enum, required, WITHOUT fk_column - let relation_fields = vec![create_test_relation_info_full( - "author", - "BelongsTo", - quote! { user::Schema }, - false, // required - Some("Author".to_string()), // relation_enum - None, // NO fk_column - None, // via_rel - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "comment".to_string(), - ]; - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - assert!(output.contains("impl CommentSchema")); - // Fallback: use Entity::find_related(Relation::Variant) - assert!( - output.contains("Entity :: find_related (Relation :: Author)"), - "Should use find_related with Relation enum: {output}" - ); - } - - // ============================================================ - // Tests for HasMany with via_rel/relation_enum - // ============================================================ - - #[test] - #[serial] - fn test_generate_from_model_has_many_with_via_rel_fk_found() { - // Tests for HasMany with via_rel + FK column found - // Tests: HasMany with via_rel + FK column found in target entity - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create notification.rs with matching relation_enum - let notification_model = r#" -pub struct Model { - pub id: i32, - pub message: String, - pub target_user_id: i32, - #[sea_orm(belongs_to = "super::user::Entity", from = "target_user_id", to = "id", relation_enum = "TargetUser")] - pub target_user: BelongsTo, -} -"#; - std::fs::write(models_dir.join("notification.rs"), notification_model).unwrap(); - - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::user::Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("target_user_notifications", proc_macro2::Span::call_site()), - syn::Ident::new("target_user_notifications", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // HasMany with via_rel - let relation_fields = vec![create_test_relation_info_full( - "target_user_notifications", - "HasMany", - quote! { crate::models::notification::Schema }, - false, - None, - None, - Some("TargetUser".to_string()), // via_rel - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl UserSchema")); - // Should generate FK-based query - assert!( - output.contains("TargetUserId"), - "Should have FK column identifier: {output}" - ); - assert!( - output.contains("into_column ()"), - "Should have into_column: {output}" - ); - assert!( - output.contains("eq (model . id . clone ())"), - "Should compare with model.id: {output}" - ); - assert!( - output.contains(". all (db)"), - "Should use .all(db): {output}" - ); - } - - #[test] - #[serial] - fn test_generate_from_model_has_many_with_via_rel_fk_not_found() { - // Tests for HasMany via_rel not found - // Tests: HasMany with via_rel but FK column NOT found in target entity - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create notification.rs WITHOUT matching relation_enum - let notification_model = r" -pub struct Model { - pub id: i32, - pub message: String, -} -"; - std::fs::write(models_dir.join("notification.rs"), notification_model).unwrap(); - - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::user::Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("notifications", proc_macro2::Span::call_site()), - syn::Ident::new("notifications", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // HasMany with via_rel that won't find FK - let relation_fields = vec![create_test_relation_info_full( - "notifications", - "HasMany", - quote! { crate::models::notification::Schema }, - false, - None, - None, - Some("NonExistentRelation".to_string()), // via_rel that won't match - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl UserSchema")); - // Should fall back to empty vec (WARNING comment won't appear in TokenStream) - assert!( - output.contains("vec ! []"), - "Should fall back to empty vec: {output}" - ); - } - - #[test] - #[serial] - fn test_generate_from_model_has_many_with_relation_enum_fk_found() { - // Tests for via_rel field matching - // Tests: HasMany with relation_enum (no via_rel) + FK column found - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create comment.rs with matching relation_enum - let comment_model = r#" -pub struct Model { - pub id: i32, - pub content: String, - pub author_id: i32, - #[sea_orm(belongs_to = "super::user::Entity", from = "author_id", to = "id", relation_enum = "AuthorComments")] - pub author: BelongsTo, -} -"#; - std::fs::write(models_dir.join("comment.rs"), comment_model).unwrap(); - - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::user::Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("author_comments", proc_macro2::Span::call_site()), - syn::Ident::new("author_comments", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // HasMany with relation_enum (no via_rel) - let relation_fields = vec![create_test_relation_info_full( - "author_comments", - "HasMany", - quote! { crate::models::comment::Schema }, - false, - Some("AuthorComments".to_string()), // relation_enum - None, - None, // NO via_rel - will use relation_enum as via_rel - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl UserSchema")); - // Should generate FK-based query using relation_enum as via_rel - assert!( - output.contains("AuthorId"), - "Should have FK column identifier: {output}" - ); - assert!( - output.contains("into_column ()"), - "Should have into_column: {output}" - ); - assert!( - output.contains(". all (db)"), - "Should use .all(db): {output}" - ); - } - - #[test] - #[serial] - fn test_generate_from_model_has_many_with_relation_enum_fk_not_found() { - // Tests for HasMany via_rel generation - // Tests: HasMany with relation_enum (no via_rel) + FK column NOT found - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create post.rs WITHOUT matching relation_enum - let post_model = r" -pub struct Model { - pub id: i32, - pub title: String, -} -"; - std::fs::write(models_dir.join("post.rs"), post_model).unwrap(); - - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::user::Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("authored_posts", proc_macro2::Span::call_site()), - syn::Ident::new("authored_posts", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // HasMany with relation_enum that won't match (no via_rel) - let relation_fields = vec![create_test_relation_info_full( - "authored_posts", - "HasMany", - quote! { crate::models::post::Schema }, - false, - Some("NonExistentRelation".to_string()), // relation_enum that won't match - None, - None, // NO via_rel - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl UserSchema")); - // Should fall back to empty vec (WARNING comment won't appear in TokenStream) - assert!( - output.contains("vec ! []"), - "Should fall back to empty vec: {output}" + // Entity-path derivation: the rewritten PATH is the whole contract — + // each case snapshots the exact token output (e.g. `Schema` tail must + // become `Entity`, all module segments preserved) instead of probing + // substrings. Snapshot names are explicit because insta's + // auto-naming shuffles across parallel rstest cases. + #[rstest] + #[case::crate_qualified("entity_path_crate_qualified", quote! { crate::models::user::Schema })] + #[case::simple_module("entity_path_simple_module", quote! { user::Schema })] + #[case::deeply_nested( + "entity_path_deeply_nested", + quote! { crate::api::models::entities::user::Schema } + )] + #[case::single_segment("entity_path_single_segment", quote! { Schema })] + fn build_entity_path_from_schema_path_snapshot( + #[case] snapshot_name: &str, + #[case] schema_path: TokenStream, + ) { + insta::assert_snapshot!( + snapshot_name, + build_entity_path_from_schema_path(&schema_path, &[]).to_string() ); } } diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate.rs b/crates/vespera_macro/src/schema_macro/from_model/generate.rs new file mode 100644 index 00000000..154f551d --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate.rs @@ -0,0 +1,826 @@ +//! Async `from_model` impl generation for SeaORM models with +//! relations (circular handling, FK lookups, parent stubs). + +use std::collections::HashMap; + +use proc_macro2::TokenStream; +use quote::quote; +use syn::Type; + +use super::super::{ + circular::{generate_inline_struct_construction, generate_inline_type_construction}, + file_cache::{get_circular_analysis, get_fk_column, get_struct_from_schema_path}, + seaorm::RelationFieldInfo, + type_utils::{normalize_token_str, snake_to_pascal_case}, +}; +use super::build_entity_path_from_schema_path; +use crate::metadata::StructMetadata; + +/// Generate `from_model` impl for `SeaORM` Model WITH relations (async version). +/// +/// When circular references are detected, generates inline struct construction +/// that excludes circular fields (sets them to default values). +/// +/// ```ignore +/// impl NewType { +/// pub async fn from_model( +/// model: SourceType, +/// db: &sea_orm::DatabaseConnection, +/// ) -> Result { +/// // Load related entities +/// let user = model.find_related(user::Entity).one(db).await?; +/// let tags = model.find_related(tag::Entity).all(db).await?; +/// +/// Ok(Self { +/// id: model.id, +/// // Inline construction with circular field defaulted: +/// user: user.map(|r| Box::new(user::Schema { id: r.id, memos: vec![], ... })), +/// tags: tags.into_iter().map(|r| tag::Schema { ... }).collect(), +/// }) +/// } +/// } +/// ``` +#[allow(clippy::too_many_lines, clippy::option_if_let_else)] +pub fn generate_from_model_with_relations( + new_type_name: &syn::Ident, + source_type: &Type, + field_mappings: &[(syn::Ident, syn::Ident, bool, bool)], + relation_fields: &[RelationFieldInfo], + source_module_path: &[String], + _schema_storage: &HashMap, +) -> TokenStream { + // Build relation loading statements + let relation_loads: Vec = relation_fields + .iter() + .map(|rel| { + let field_name = &rel.field_name; + let entity_path = build_entity_path_from_schema_path(&rel.schema_path, source_module_path); + + match rel.relation_type.as_str() { + "HasOne" | "BelongsTo" => { + // When relation_enum is specified, use the specific Relation variant + // This handles cases where multiple relations point to the same Entity type + if let Some(ref relation_enum_name) = rel.relation_enum { + let relation_variant = syn::Ident::new(relation_enum_name, proc_macro2::Span::call_site()); + + if rel.is_optional { + // Optional FK: load only if FK value exists + if let Some(ref fk_col) = rel.fk_column { + let fk_ident = syn::Ident::new(fk_col, proc_macro2::Span::call_site()); + quote! { + let #field_name = match &model.#fk_ident { + Some(fk_value) => #entity_path::find_by_id(fk_value.clone()).one(db).await?, + None => None, + }; + } + } else { + // Fallback: use find_related with Relation enum + quote! { + let #field_name = Entity::find_related(Relation::#relation_variant) + .filter(::PrimaryKey::eq(&model)) + .one(db) + .await?; + } + } + } else { + // Required FK: directly query by FK value + if let Some(ref fk_col) = rel.fk_column { + let fk_ident = syn::Ident::new(fk_col, proc_macro2::Span::call_site()); + quote! { + let #field_name = #entity_path::find_by_id(model.#fk_ident.clone()).one(db).await?; + } + } else { + // Fallback: use find_related with Relation enum + quote! { + let #field_name = Entity::find_related(Relation::#relation_variant) + .filter(::PrimaryKey::eq(&model)) + .one(db) + .await?; + } + } + } + } else { + // Standard case: single relation to target entity, use find_related + quote! { + let #field_name = model.find_related(#entity_path).one(db).await?; + } + } + } + "HasMany" => { + // Try via_rel first, fall back to relation_enum as FK source + let fk_rel_source = rel.via_rel.as_ref().or(rel.relation_enum.as_ref()); + if let Some(via_rel_value) = fk_rel_source { + let schema_path_str = normalize_token_str(&rel.schema_path); + if let Some(fk_col_name) = get_fk_column(&schema_path_str, via_rel_value) { + let fk_col_pascal = snake_to_pascal_case(&fk_col_name); + let fk_col_ident = syn::Ident::new(&fk_col_pascal, proc_macro2::Span::call_site()); + + let entity_path_str = normalize_token_str(&entity_path); + let column_path_str = entity_path_str.replace(":: Entity", ":: Column"); + let column_path_idents: Vec = column_path_str + .split("::") + .filter_map(|s| { + let trimmed = s.trim(); + if trimmed.is_empty() { None } else { Some(syn::Ident::new(trimmed, proc_macro2::Span::call_site())) } + }) + .collect(); + + quote! { + let #field_name = #(#column_path_idents)::*::#fk_col_ident + .into_column() + .eq(model.id.clone()) + .into_condition(); + let #field_name = #entity_path::find() + .filter(#field_name) + .all(db) + .await?; + } + } else { + quote! { + // WARNING: Could not find FK column for relation, using empty vec + let #field_name: Vec<_> = vec![]; + } + } + } else { + // Standard HasMany - use find_related + quote! { + let #field_name = model.find_related(#entity_path).all(db).await?; + } + } + } + _ => quote! {}, + } + }) + .collect(); + + // Check if we need a parent stub for HasMany relations with required circular back-refs + // This is needed when: UserSchema.memos has MemoSchema which has required user: Box + // BUT: If the relation uses an inline type (which excludes circular fields), we don't need a parent stub + let needs_parent_stub = relation_fields.iter().any(|rel| { + if rel.relation_type != "HasMany" { + return false; + } + // If using inline type, circular fields are excluded, so no parent stub needed + if rel.inline_type_info.is_some() { + return false; + } + let schema_path_str = normalize_token_str(&rel.schema_path); + let model_path_str = schema_path_str.replace("::Schema", "::Model"); + let related_model = get_struct_from_schema_path(&model_path_str); + + if let Some(ref model) = related_model { + let analysis = get_circular_analysis(source_module_path, &model.definition); + // Check if any circular field is a required relation + analysis.circular_fields.iter().any(|cf| { + analysis + .circular_field_required + .get(cf) + .copied() + .unwrap_or(false) + }) + } else { + false + } + }); + + // Generate parent stub field assignments (non-relation fields from model) + let parent_stub_fields: Vec = if needs_parent_stub { + field_mappings + .iter() + .map(|(new_ident, source_ident, _wrapped, is_relation)| { + if *is_relation { + // For relation fields in stub, use defaults + if let Some(rel) = relation_fields + .iter() + .find(|r| &r.field_name == source_ident) + { + match rel.relation_type.as_str() { + "HasMany" => quote! { #new_ident: vec![] }, + _ if rel.is_optional => quote! { #new_ident: None }, + // Required single relations in parent stub - this shouldn't happen + // as we're creating stub to break circular ref + _ => quote! { #new_ident: None }, + } + } else { + quote! { #new_ident: Default::default() } + } + } else { + // Regular field - clone from model + quote! { #new_ident: model.#source_ident.clone() } + } + }) + .collect() + } else { + vec![] + }; + + // Pre-build relation lookup for O(1) access in field assignments loop + let relation_by_name: HashMap<&syn::Ident, &RelationFieldInfo> = relation_fields + .iter() + .map(|rel| (&rel.field_name, rel)) + .collect(); + + // Build field assignments + // For relation fields, check for circular references and use inline construction if needed + let field_assignments: Vec = field_mappings + .iter() + .map(|(new_ident, source_ident, wrapped, is_relation)| { + if *is_relation { + // Find the relation info for this field + if let Some(rel) = relation_by_name.get(source_ident) { + let schema_path = &rel.schema_path; + + // Try to find the related MODEL definition to check for circular refs + // The schema_path is like "crate::models::user::Schema", but the actual + // struct is "Model" in the same module. We need to look up the Model + // to see if it has relations pointing back to us. + let schema_path_str = normalize_token_str(schema_path); + + // Convert schema path to model path: Schema -> Model + let model_path_str = schema_path_str.replace("::Schema", "::Model"); + + // Try to find the related Model definition from file + let related_model_from_file = get_struct_from_schema_path(&model_path_str); + + // Get the definition string + let related_def_str = related_model_from_file.as_ref().map_or("", |s| s.definition.as_str()); + + // Analyze circular references, FK relations, and FK optionality in ONE pass + let analysis = get_circular_analysis(source_module_path, related_def_str); + let circular_fields = &analysis.circular_fields; + let has_circular = !circular_fields.is_empty(); + + // Check if we have inline type info - if so, use the inline type + // instead of the original schema path + if let Some((ref inline_type_name, ref included_fields)) = rel.inline_type_info { + // Use inline type construction + let inline_construct = generate_inline_type_construction(inline_type_name, included_fields, related_def_str, "r"); + + match rel.relation_type.as_str() { + "HasOne" | "BelongsTo" => { + if rel.is_optional { + quote! { + #new_ident: #source_ident.map(|r| Box::new(#inline_construct)) + } + } else { + quote! { + #new_ident: Box::new({ + let r = #source_ident.ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!("Required relation '{}' not found", stringify!(#source_ident)) + ))?; + #inline_construct + }) + } + } + } + "HasMany" => { + quote! { + #new_ident: #source_ident.into_iter().map(|r| #inline_construct).collect() + } + } + _ => quote! { #new_ident: Default::default() }, + } + } else { + // No inline type - use original behavior + match rel.relation_type.as_str() { + "HasOne" | "BelongsTo" => { + if has_circular { + // Use inline construction to break circular ref + let inline_construct = generate_inline_struct_construction(schema_path, related_def_str, circular_fields, "r"); + if rel.is_optional { + quote! { + #new_ident: #source_ident.map(|r| Box::new(#inline_construct)) + } + } else { + quote! { + #new_ident: Box::new({ + let r = #source_ident.ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!("Required relation '{}' not found", stringify!(#source_ident)) + ))?; + #inline_construct + }) + } + } + } else { + // No circular ref - use has_fk_relations from the analysis + let target_has_fk = analysis.has_fk_relations; + + if target_has_fk { + // Target schema has FK relations -> use async from_model() + if rel.is_optional { + quote! { + #new_ident: match #source_ident { + Some(r) => Some(Box::new(#schema_path::from_model(r, db).await?)), + None => None, + } + } + } else { + quote! { + #new_ident: Box::new(#schema_path::from_model( + #source_ident.ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!("Required relation '{}' not found", stringify!(#source_ident)) + ))?, + db, + ).await?) + } + } + } else { + // Target schema has no FK relations -> use sync From::from() + if rel.is_optional { + quote! { + #new_ident: #source_ident.map(|r| Box::new(<#schema_path as From<_>>::from(r))) + } + } else { + quote! { + #new_ident: Box::new(<#schema_path as From<_>>::from( + #source_ident.ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!("Required relation '{}' not found", stringify!(#source_ident)) + ))? + )) + } + } + } + } + } + "HasMany" => { + // HasMany is excluded by default, so this branch is only hit + // when explicitly picked. Use inline construction (no relations). + if has_circular { + // Use inline construction to break circular ref + let inline_construct = generate_inline_struct_construction(schema_path, related_def_str, circular_fields, "r"); + quote! { + #new_ident: #source_ident.into_iter().map(|r| #inline_construct).collect() + } + } else { + // No circular ref - use has_fk_relations from the analysis + let target_has_fk = analysis.has_fk_relations; + + if target_has_fk { + // Target has FK relations but HasMany doesn't load nested data anyway, + // so we use inline construction (flat fields only) + let inline_construct = generate_inline_struct_construction( + schema_path, + related_def_str, + &[], // no circular fields to exclude + "r", + ); + quote! { + #new_ident: #source_ident.into_iter().map(|r| #inline_construct).collect() + } + } else { + quote! { + #new_ident: #source_ident.into_iter().map(|r| <#schema_path as From<_>>::from(r)).collect() + } + } + } + } + _ => quote! { #new_ident: Default::default() }, + } + } + } else { + quote! { #new_ident: Default::default() } + } + } else if *wrapped { + quote! { #new_ident: Some(model.#source_ident) } + } else { + quote! { #new_ident: model.#source_ident } + } + }) + .collect(); + + // Circular references are now handled automatically via inline construction + // For HasMany with required circular back-refs, we create a parent stub first + + // Generate parent stub definition if needed + let parent_stub_def = if needs_parent_stub { + quote! { + let __parent_stub__ = Self { + #(#parent_stub_fields),* + }; + } + } else { + quote! {} + }; + + quote! { + impl #new_type_name { + pub async fn from_model( + model: #source_type, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + + #(#relation_loads)* + + #parent_stub_def + + Ok(Self { + #(#field_assignments),* + }) + } + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use rstest::rstest; + use serial_test::serial; + + use super::*; + + // ── Test support ───────────────────────────────────────────────── + // + // Every scenario snapshots the FULL generated `impl` (pretty-printed + // Rust) under an explicit name — one reviewable artifact per code + // path instead of fragile `contains` probes. All cases run + // `#[serial]` inside a temp `CARGO_MANIFEST_DIR` so file-lookup + // branches are deterministic and isolated. + + fn pretty(tokens: &TokenStream) -> String { + let file: syn::File = + syn::parse2(tokens.clone()).expect("generated tokens must parse as Rust items"); + prettyplease::unparse(&file) + } + + /// `(source_field, target_field, wrapped, is_relation)` mapping row. + type MappingRow = (&'static str, &'static str, bool, bool); + + fn mappings(rows: &[MappingRow]) -> Vec<(syn::Ident, syn::Ident, bool, bool)> { + rows.iter() + .map(|(source, target, wrapped, is_relation)| { + ( + syn::Ident::new(source, proc_macro2::Span::call_site()), + syn::Ident::new(target, proc_macro2::Span::call_site()), + *wrapped, + *is_relation, + ) + }) + .collect() + } + + fn rel( + field_name: &str, + relation_type: &str, + schema_path: TokenStream, + is_optional: bool, + ) -> RelationFieldInfo { + RelationFieldInfo { + field_name: syn::Ident::new(field_name, proc_macro2::Span::call_site()), + relation_type: relation_type.to_string(), + schema_path, + is_optional, + inline_type_info: None, + relation_enum: None, + fk_column: None, + via_rel: None, + } + } + + fn with_inline( + mut info: RelationFieldInfo, + type_name: &str, + fields: &[&str], + ) -> RelationFieldInfo { + info.inline_type_info = Some(( + syn::Ident::new(type_name, proc_macro2::Span::call_site()), + fields.iter().map(ToString::to_string).collect(), + )); + info + } + + fn with_enum( + mut info: RelationFieldInfo, + relation_enum: Option<&str>, + fk_column: Option<&str>, + via_rel: Option<&str>, + ) -> RelationFieldInfo { + info.relation_enum = relation_enum.map(ToString::to_string); + info.fk_column = fk_column.map(ToString::to_string); + info.via_rel = via_rel.map(ToString::to_string); + info + } + + /// Model fixtures written under the temp project''s `src/models/`. + const USER_PLAIN: &str = "pub struct Model {\n pub id: i32,\n pub name: String,\n}"; + const MEMO_REQUIRED_CIRCULAR: &str = "pub struct Model {\n pub id: i32,\n pub title: String,\n pub user_id: i32,\n #[sea_orm(belongs_to = \"super::user::Entity\", from = \"user_id\")]\n pub user: BelongsTo,\n}"; + const MEMO_CIRCULAR: &str = "pub struct Model {\n pub id: i32,\n pub title: String,\n pub user: BelongsTo,\n}"; + const PROFILE_CIRCULAR: &str = "pub struct Model {\n pub id: i32,\n pub bio: String,\n pub user: BelongsTo,\n}"; + const PROFILE_PLAIN: &str = "pub struct Model {\n pub id: i32,\n pub bio: String,\n}"; + const SETTINGS_PLAIN: &str = "pub struct Model {\n pub id: i32,\n pub theme: String,\n}"; + const ADDRESS_FK: &str = "pub struct Model {\n pub id: i32,\n pub street: String,\n pub city_id: i32,\n pub city: BelongsTo,\n}"; + const TAG_FK: &str = "pub struct Model {\n pub id: i32,\n pub name: String,\n pub category_id: i32,\n pub category: BelongsTo,\n}"; + const NOTIFICATION_TARGET_USER: &str = "pub struct Model {\n pub id: i32,\n pub message: String,\n pub target_user_id: i32,\n #[sea_orm(belongs_to = \"super::user::Entity\", from = \"target_user_id\", to = \"id\", relation_enum = \"TargetUser\")]\n pub target_user: BelongsTo,\n}"; + const NOTIFICATION_PLAIN: &str = + "pub struct Model {\n pub id: i32,\n pub message: String,\n}"; + const COMMENT_AUTHOR_ENUM: &str = "pub struct Model {\n pub id: i32,\n pub content: String,\n pub author_id: i32,\n #[sea_orm(belongs_to = \"super::user::Entity\", from = \"author_id\", to = \"id\", relation_enum = \"AuthorComments\")]\n pub author: BelongsTo,\n}"; + const POST_PLAIN: &str = "pub struct Model {\n pub id: i32,\n pub title: String,\n}"; + + /// Run one scenario inside a temp project and return the pretty + /// impl for snapshotting. + #[allow(clippy::too_many_arguments)] + fn run_scenario( + models: &[(&str, &str)], + new_type: &str, + source_type: &str, + rows: &[MappingRow], + relations: &[RelationFieldInfo], + module: &[&str], + ) -> String { + let temp_dir = tempfile::TempDir::new().unwrap(); + let models_dir = temp_dir.path().join("src").join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + for (file, source) in models { + std::fs::write(models_dir.join(file), source).unwrap(); + } + + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + // SAFETY: every caller is a #[serial] test. + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + + let tokens = generate_from_model_with_relations( + &syn::Ident::new(new_type, proc_macro2::Span::call_site()), + &syn::parse_str::(source_type).unwrap(), + &mappings(rows), + relations, + &module.iter().map(ToString::to_string).collect::>(), + &HashMap::new(), + ); + + // SAFETY: same as above. + unsafe { + match original { + Some(dir) => std::env::set_var("CARGO_MANIFEST_DIR", dir), + None => std::env::remove_var("CARGO_MANIFEST_DIR"), + } + } + + pretty(&tokens) + } + + // ── Scenario table ─────────────────────────────────────────────── + + #[rstest] + // Plain shapes (no on-disk models needed). + #[case::no_relations( + "no_relations", &[], "SimpleSchema", "Model", + &[("id", "id", false, false), ("name", "name", false, false)], + vec![], &["crate"] + )] + #[case::wrapped_field( + "wrapped_field", &[], "TestSchema", "Model", + &[("id", "id", true, false)], + vec![], &["crate"] + )] + #[case::has_one_required_simple( + "has_one_required_simple", &[], "MemoSchema", "Model", + &[("id", "id", false, false), ("user", "user", false, true)], + vec![rel("user", "HasOne", quote! { user::Schema }, false)], + &["crate", "models", "memo"] + )] + #[case::has_one_optional_simple( + "has_one_optional_simple", &[], "MemoSchema", "Model", + &[("id", "id", false, false), ("user", "user", false, true)], + vec![rel("user", "HasOne", quote! { user::Schema }, true)], + &["crate", "models", "memo"] + )] + #[case::has_many_simple( + "has_many_simple", &[], "UserSchema", "Model", + &[("id", "id", false, false), ("memos", "memos", false, true)], + vec![rel("memos", "HasMany", quote! { memo::Schema }, false)], + &["crate", "models", "user"] + )] + #[case::belongs_to_optional_simple( + "belongs_to_optional_simple", &[], "MemoSchema", "Model", + &[("id", "id", false, false), ("user", "user", false, true)], + vec![rel("user", "BelongsTo", quote! { user::Schema }, true)], + &["crate", "models", "memo"] + )] + #[case::has_one_optional_inline_type( + "has_one_optional_inline_type", &[], "MemoSchema", "Model", + &[("id", "id", false, false), ("user", "user", false, true)], + vec![with_inline( + rel("user", "HasOne", quote! { user::Schema }, true), + "MemoSchema_User", &["id", "name"], + )], + &["crate", "models", "memo"] + )] + #[case::has_many_inline_type( + "has_many_inline_type", &[], "UserSchema", "Model", + &[("id", "id", false, false), ("memos", "memos", false, true)], + vec![with_inline( + rel("memos", "HasMany", quote! { memo::Schema }, false), + "UserSchema_Memos", &["id", "title"], + )], + &["crate", "models", "user"] + )] + #[case::unknown_relation_type( + "unknown_relation_type", &[], "TestSchema", "Model", + &[("id", "id", false, false), ("unknown", "unknown", false, true)], + vec![rel("unknown", "UnknownType", quote! { some::Schema }, true)], + &["crate"] + )] + #[case::unknown_relation_with_inline_type( + "unknown_relation_with_inline_type", &[], "TestSchema", "Model", + &[("id", "id", false, false), ("weird", "weird", false, true)], + vec![with_inline( + rel("weird", "UnknownRelationType", quote! { some::Schema }, true), + "TestSchema_Weird", &["id"], + )], + &["crate"] + )] + #[case::relation_field_not_in_mappings( + "relation_field_not_in_mappings", &[], "TestSchema", "Model", + &[("id", "id", false, false), ("owner", "different_name", false, true)], + vec![rel("user", "HasOne", quote! { user::Schema }, true)], + &["crate"] + )] + // relation_enum / fk_column branches. + #[case::enum_has_one_optional_with_fk( + "enum_has_one_optional_with_fk", &[], "MemoSchema", "Model", + &[("id", "id", false, false), ("target_user", "target_user", false, true)], + vec![with_enum( + rel("target_user", "HasOne", quote! { user::Schema }, true), + Some("TargetUser"), Some("target_user_id"), None, + )], + &["crate", "models", "memo"] + )] + #[case::enum_has_one_optional_no_fk( + "enum_has_one_optional_no_fk", &[], "MemoSchema", "Model", + &[("id", "id", false, false), ("author", "author", false, true)], + vec![with_enum( + rel("author", "HasOne", quote! { user::Schema }, true), + Some("Author"), None, None, + )], + &["crate", "models", "memo"] + )] + #[case::enum_belongs_to_required_with_fk( + "enum_belongs_to_required_with_fk", &[], "CommentSchema", "Model", + &[("id", "id", false, false), ("post", "post", false, true)], + vec![with_enum( + rel("post", "BelongsTo", quote! { post::Schema }, false), + Some("Post"), Some("post_id"), None, + )], + &["crate", "models", "comment"] + )] + #[case::enum_belongs_to_required_no_fk( + "enum_belongs_to_required_no_fk", &[], "CommentSchema", "Model", + &[("id", "id", false, false), ("author", "author", false, true)], + vec![with_enum( + rel("author", "BelongsTo", quote! { user::Schema }, false), + Some("Author"), None, None, + )], + &["crate", "models", "comment"] + )] + // File-lookup branches (models on disk). + #[case::parent_stub_required_circular( + "parent_stub_required_circular", + &[("memo.rs", MEMO_REQUIRED_CIRCULAR), ("user.rs", USER_PLAIN)], + "UserSchema", "crate::models::user::Model", + &[("id", "id", false, false), ("name", "name", false, false), ("memos", "memos", false, true)], + vec![rel("memos", "HasMany", quote! { crate::models::memo::Schema }, false)], + &["crate", "models", "user"] + )] + #[case::circular_has_one_optional( + "circular_has_one_optional", + &[("profile.rs", PROFILE_CIRCULAR)], + "UserSchema", "crate::models::user::Model", + &[("id", "id", false, false), ("profile", "profile", false, true)], + vec![rel("profile", "HasOne", quote! { crate::models::profile::Schema }, true)], + &["crate", "models", "user"] + )] + #[case::circular_has_one_required( + "circular_has_one_required", + &[("profile.rs", PROFILE_CIRCULAR)], + "UserSchema", "crate::models::user::Model", + &[("id", "id", false, false), ("profile", "profile", false, true)], + vec![rel("profile", "HasOne", quote! { crate::models::profile::Schema }, false)], + &["crate", "models", "user"] + )] + #[case::non_circular_has_one_fk_optional( + "non_circular_has_one_fk_optional", + &[("address.rs", ADDRESS_FK)], + "UserSchema", "crate::models::user::Model", + &[("id", "id", false, false), ("address", "address", false, true)], + vec![rel("address", "HasOne", quote! { crate::models::address::Schema }, true)], + &["crate", "models", "user"] + )] + #[case::non_circular_has_one_fk_required( + "non_circular_has_one_fk_required", + &[("address.rs", ADDRESS_FK)], + "UserSchema", "crate::models::user::Model", + &[("id", "id", false, false), ("address", "address", false, true)], + vec![rel("address", "HasOne", quote! { crate::models::address::Schema }, false)], + &["crate", "models", "user"] + )] + #[case::has_many_circular( + "has_many_circular", + &[("memo.rs", MEMO_CIRCULAR)], + "UserSchema", "crate::models::user::Model", + &[("id", "id", false, false), ("memos", "memos", false, true)], + vec![rel("memos", "HasMany", quote! { crate::models::memo::Schema }, false)], + &["crate", "models", "user"] + )] + #[case::has_many_fk_no_circular( + "has_many_fk_no_circular", + &[("tag.rs", TAG_FK)], + "UserSchema", "crate::models::user::Model", + &[("id", "id", false, false), ("tags", "tags", false, true)], + vec![rel("tags", "HasMany", quote! { crate::models::tag::Schema }, false)], + &["crate", "models", "user"] + )] + #[case::inline_type_required_belongs_to( + "inline_type_required_belongs_to", + &[("user.rs", USER_PLAIN)], + "MemoSchema", "crate::models::memo::Model", + &[("id", "id", false, false), ("user", "user", false, true)], + vec![with_inline( + rel("user", "BelongsTo", quote! { crate::models::user::Schema }, false), + "MemoSchema_User", &["id", "name"], + )], + &["crate", "models", "memo"] + )] + #[case::parent_stub_all_relation_types( + "parent_stub_all_relation_types", + &[ + ("memo.rs", MEMO_REQUIRED_CIRCULAR), + ("profile.rs", PROFILE_PLAIN), + ("settings.rs", SETTINGS_PLAIN), + ], + "UserSchema", "crate::models::user::Model", + &[ + ("id", "id", false, false), + ("memos", "memos", false, true), + ("profile", "profile", false, true), + ("settings", "settings", false, true), + ("orphan_rel", "orphan_rel", false, true), + ], + vec![ + rel("memos", "HasMany", quote! { crate::models::memo::Schema }, false), + rel("profile", "HasOne", quote! { crate::models::profile::Schema }, true), + rel("settings", "BelongsTo", quote! { crate::models::settings::Schema }, false), + ], + &["crate", "models", "user"] + )] + #[case::has_many_via_rel_fk_found( + "has_many_via_rel_fk_found", + &[("notification.rs", NOTIFICATION_TARGET_USER)], + "UserSchema", "crate::models::user::Model", + &[("id", "id", false, false), ("target_user_notifications", "target_user_notifications", false, true)], + vec![with_enum( + rel("target_user_notifications", "HasMany", quote! { crate::models::notification::Schema }, false), + None, None, Some("TargetUser"), + )], + &["crate", "models", "user"] + )] + #[case::has_many_via_rel_fk_not_found( + "has_many_via_rel_fk_not_found", + &[("notification.rs", NOTIFICATION_PLAIN)], + "UserSchema", "crate::models::user::Model", + &[("id", "id", false, false), ("notifications", "notifications", false, true)], + vec![with_enum( + rel("notifications", "HasMany", quote! { crate::models::notification::Schema }, false), + None, None, Some("NonExistentRelation"), + )], + &["crate", "models", "user"] + )] + #[case::has_many_enum_fk_found( + "has_many_enum_fk_found", + &[("comment.rs", COMMENT_AUTHOR_ENUM)], + "UserSchema", "crate::models::user::Model", + &[("id", "id", false, false), ("author_comments", "author_comments", false, true)], + vec![with_enum( + rel("author_comments", "HasMany", quote! { crate::models::comment::Schema }, false), + Some("AuthorComments"), None, None, + )], + &["crate", "models", "user"] + )] + #[case::has_many_enum_fk_not_found( + "has_many_enum_fk_not_found", + &[("post.rs", POST_PLAIN)], + "UserSchema", "crate::models::user::Model", + &[("id", "id", false, false), ("authored_posts", "authored_posts", false, true)], + vec![with_enum( + rel("authored_posts", "HasMany", quote! { crate::models::post::Schema }, false), + Some("NonExistentRelation"), None, None, + )], + &["crate", "models", "user"] + )] + #[serial] + fn generate_from_model_scenario_snapshot( + #[case] snapshot_name: &str, + #[case] models: &[(&str, &str)], + #[case] new_type: &str, + #[case] source_type: &str, + #[case] rows: &[MappingRow], + #[case] relations: Vec, + #[case] module: &[&str], + ) { + insta::assert_snapshot!( + snapshot_name, + run_scenario(models, new_type, source_type, rows, &relations, module) + ); + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__belongs_to_optional_simple.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__belongs_to_optional_simple.snap new file mode 100644 index 00000000..6f9eadd9 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__belongs_to_optional_simple.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl MemoSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let user = model.find_related(user::Entity).one(db).await?; + Ok(Self { + id: model.id, + user: user.map(|r| Box::new(>::from(r))), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__circular_has_one_optional.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__circular_has_one_optional.snap new file mode 100644 index 00000000..ee261bce --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__circular_has_one_optional.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: crate::models::user::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let profile = model.find_related(crate::models::profile::Entity).one(db).await?; + Ok(Self { + id: model.id, + profile: profile + .map(|r| Box::new(crate::models::profile::Schema { + id: r.id, + bio: r.bio, + user: None, + })), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__circular_has_one_required.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__circular_has_one_required.snap new file mode 100644 index 00000000..793c45bd --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__circular_has_one_required.snap @@ -0,0 +1,27 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: crate::models::user::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let profile = model.find_related(crate::models::profile::Entity).one(db).await?; + Ok(Self { + id: model.id, + profile: Box::new({ + let r = profile + .ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!("Required relation '{}' not found", stringify!(profile)), + ))?; + crate::models::profile::Schema { + id: r.id, + bio: r.bio, + user: None, + } + }), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__enum_belongs_to_required_no_fk.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__enum_belongs_to_required_no_fk.snap new file mode 100644 index 00000000..6cd4dcdc --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__enum_belongs_to_required_no_fk.snap @@ -0,0 +1,31 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl CommentSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let author = Entity::find_related(Relation::Author) + .filter(::PrimaryKey::eq(&model)) + .one(db) + .await?; + Ok(Self { + id: model.id, + author: Box::new( + >::from( + author + .ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!( + "Required relation '{}' not found", stringify!(author) + ), + ))?, + ), + ), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__enum_belongs_to_required_with_fk.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__enum_belongs_to_required_with_fk.snap new file mode 100644 index 00000000..e2d33561 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__enum_belongs_to_required_with_fk.snap @@ -0,0 +1,26 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl CommentSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let post = post::Entity::find_by_id(model.post_id.clone()).one(db).await?; + Ok(Self { + id: model.id, + post: Box::new( + >::from( + post + .ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!("Required relation '{}' not found", stringify!(post)), + ))?, + ), + ), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__enum_has_one_optional_no_fk.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__enum_has_one_optional_no_fk.snap new file mode 100644 index 00000000..54565ced --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__enum_has_one_optional_no_fk.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl MemoSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let author = Entity::find_related(Relation::Author) + .filter(::PrimaryKey::eq(&model)) + .one(db) + .await?; + Ok(Self { + id: model.id, + author: author.map(|r| Box::new(>::from(r))), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__enum_has_one_optional_with_fk.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__enum_has_one_optional_with_fk.snap new file mode 100644 index 00000000..82991293 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__enum_has_one_optional_with_fk.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl MemoSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let target_user = match &model.target_user_id { + Some(fk_value) => user::Entity::find_by_id(fk_value.clone()).one(db).await?, + None => None, + }; + Ok(Self { + id: model.id, + target_user: target_user + .map(|r| Box::new(>::from(r))), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_circular.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_circular.snap new file mode 100644 index 00000000..6a2b67bb --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_circular.snap @@ -0,0 +1,24 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: crate::models::user::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let memos = model.find_related(crate::models::memo::Entity).all(db).await?; + Ok(Self { + id: model.id, + memos: memos + .into_iter() + .map(|r| crate::models::memo::Schema { + id: r.id, + title: r.title, + user: None, + }) + .collect(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_enum_fk_found.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_enum_fk_found.snap new file mode 100644 index 00000000..32176b0c --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_enum_fk_found.snap @@ -0,0 +1,36 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: crate::models::user::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let author_comments = crate::models::comment::Entity::AuthorId + .into_column() + .eq(model.id.clone()) + .into_condition(); + let author_comments = crate::models::comment::Entity::find() + .filter(author_comments) + .all(db) + .await?; + let __parent_stub__ = Self { + id: model.id.clone(), + author_comments: vec![], + }; + Ok(Self { + id: model.id, + author_comments: author_comments + .into_iter() + .map(|r| crate::models::comment::Schema { + id: r.id, + content: r.content, + author_id: r.author_id, + author: Box::new(__parent_stub__.clone()), + }) + .collect(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_enum_fk_not_found.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_enum_fk_not_found.snap new file mode 100644 index 00000000..fd7a0638 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_enum_fk_not_found.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: crate::models::user::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let authored_posts: Vec<_> = vec![]; + Ok(Self { + id: model.id, + authored_posts: authored_posts + .into_iter() + .map(|r| >::from(r)) + .collect(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_fk_no_circular.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_fk_no_circular.snap new file mode 100644 index 00000000..848411ba --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_fk_no_circular.snap @@ -0,0 +1,25 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: crate::models::user::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let tags = model.find_related(crate::models::tag::Entity).all(db).await?; + Ok(Self { + id: model.id, + tags: tags + .into_iter() + .map(|r| crate::models::tag::Schema { + id: r.id, + name: r.name, + category_id: r.category_id, + category: None, + }) + .collect(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_inline_type.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_inline_type.snap new file mode 100644 index 00000000..8cdc58ae --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_inline_type.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let memos = model.find_related(memo::Entity).all(db).await?; + Ok(Self { + id: model.id, + memos: memos.into_iter().map(|r| Default::default()).collect(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_simple.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_simple.snap new file mode 100644 index 00000000..61352ee0 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_simple.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let memos = model.find_related(memo::Entity).all(db).await?; + Ok(Self { + id: model.id, + memos: memos + .into_iter() + .map(|r| >::from(r)) + .collect(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_via_rel_fk_found.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_via_rel_fk_found.snap new file mode 100644 index 00000000..9f8e0e10 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_via_rel_fk_found.snap @@ -0,0 +1,36 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: crate::models::user::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let target_user_notifications = crate::models::notification::Entity::TargetUserId + .into_column() + .eq(model.id.clone()) + .into_condition(); + let target_user_notifications = crate::models::notification::Entity::find() + .filter(target_user_notifications) + .all(db) + .await?; + let __parent_stub__ = Self { + id: model.id.clone(), + target_user_notifications: vec![], + }; + Ok(Self { + id: model.id, + target_user_notifications: target_user_notifications + .into_iter() + .map(|r| crate::models::notification::Schema { + id: r.id, + message: r.message, + target_user_id: r.target_user_id, + target_user: Box::new(__parent_stub__.clone()), + }) + .collect(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_via_rel_fk_not_found.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_via_rel_fk_not_found.snap new file mode 100644 index 00000000..5e9ca495 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_via_rel_fk_not_found.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: crate::models::user::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let notifications: Vec<_> = vec![]; + Ok(Self { + id: model.id, + notifications: notifications + .into_iter() + .map(|r| >::from(r)) + .collect(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_one_optional_inline_type.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_one_optional_inline_type.snap new file mode 100644 index 00000000..2679de01 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_one_optional_inline_type.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl MemoSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let user = model.find_related(user::Entity).one(db).await?; + Ok(Self { + id: model.id, + user: user.map(|r| Box::new(Default::default())), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_one_optional_simple.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_one_optional_simple.snap new file mode 100644 index 00000000..6f9eadd9 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_one_optional_simple.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl MemoSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let user = model.find_related(user::Entity).one(db).await?; + Ok(Self { + id: model.id, + user: user.map(|r| Box::new(>::from(r))), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_one_required_simple.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_one_required_simple.snap new file mode 100644 index 00000000..d3f3de04 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_one_required_simple.snap @@ -0,0 +1,26 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl MemoSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let user = model.find_related(user::Entity).one(db).await?; + Ok(Self { + id: model.id, + user: Box::new( + >::from( + user + .ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!("Required relation '{}' not found", stringify!(user)), + ))?, + ), + ), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__inline_type_required_belongs_to.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__inline_type_required_belongs_to.snap new file mode 100644 index 00000000..989990fe --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__inline_type_required_belongs_to.snap @@ -0,0 +1,26 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl MemoSchema { + pub async fn from_model( + model: crate::models::memo::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let user = model.find_related(crate::models::user::Entity).one(db).await?; + Ok(Self { + id: model.id, + user: Box::new({ + let r = user + .ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!("Required relation '{}' not found", stringify!(user)), + ))?; + MemoSchema_User { + id: r.id, + name: r.name, + } + }), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__no_relations.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__no_relations.snap new file mode 100644 index 00000000..b0438281 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__no_relations.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl SimpleSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + Ok(Self { + id: model.id, + name: model.name, + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__non_circular_has_one_fk_optional.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__non_circular_has_one_fk_optional.snap new file mode 100644 index 00000000..01af6058 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__non_circular_has_one_fk_optional.snap @@ -0,0 +1,26 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: crate::models::user::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let address = model.find_related(crate::models::address::Entity).one(db).await?; + Ok(Self { + id: model.id, + address: match address { + Some(r) => { + Some( + Box::new( + crate::models::address::Schema::from_model(r, db).await?, + ), + ) + } + None => None, + }, + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__non_circular_has_one_fk_required.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__non_circular_has_one_fk_required.snap new file mode 100644 index 00000000..3142e099 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__non_circular_has_one_fk_required.snap @@ -0,0 +1,28 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: crate::models::user::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let address = model.find_related(crate::models::address::Entity).one(db).await?; + Ok(Self { + id: model.id, + address: Box::new( + crate::models::address::Schema::from_model( + address + .ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!( + "Required relation '{}' not found", stringify!(address) + ), + ))?, + db, + ) + .await?, + ), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__parent_stub_all_relation_types.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__parent_stub_all_relation_types.snap new file mode 100644 index 00000000..082139c0 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__parent_stub_all_relation_types.snap @@ -0,0 +1,52 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: crate::models::user::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let memos = model.find_related(crate::models::memo::Entity).all(db).await?; + let profile = model.find_related(crate::models::profile::Entity).one(db).await?; + let settings = model + .find_related(crate::models::settings::Entity) + .one(db) + .await?; + let __parent_stub__ = Self { + id: model.id.clone(), + memos: vec![], + profile: None, + settings: None, + orphan_rel: Default::default(), + }; + Ok(Self { + id: model.id, + memos: memos + .into_iter() + .map(|r| crate::models::memo::Schema { + id: r.id, + title: r.title, + user_id: r.user_id, + user: Box::new(__parent_stub__.clone()), + }) + .collect(), + profile: profile + .map(|r| Box::new(>::from(r))), + settings: Box::new( + >::from( + settings + .ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!( + "Required relation '{}' not found", stringify!(settings) + ), + ))?, + ), + ), + orphan_rel: Default::default(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__parent_stub_required_circular.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__parent_stub_required_circular.snap new file mode 100644 index 00000000..e8c8a48a --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__parent_stub_required_circular.snap @@ -0,0 +1,31 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: crate::models::user::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let memos = model.find_related(crate::models::memo::Entity).all(db).await?; + let __parent_stub__ = Self { + id: model.id.clone(), + name: model.name.clone(), + memos: vec![], + }; + Ok(Self { + id: model.id, + name: model.name, + memos: memos + .into_iter() + .map(|r| crate::models::memo::Schema { + id: r.id, + title: r.title, + user_id: r.user_id, + user: Box::new(__parent_stub__.clone()), + }) + .collect(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__relation_field_not_in_mappings.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__relation_field_not_in_mappings.snap new file mode 100644 index 00000000..df67e679 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__relation_field_not_in_mappings.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl TestSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let user = model.find_related(user::Entity).one(db).await?; + Ok(Self { + id: model.id, + owner: Default::default(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__unknown_relation_type.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__unknown_relation_type.snap new file mode 100644 index 00000000..b330a624 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__unknown_relation_type.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl TestSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + Ok(Self { + id: model.id, + unknown: Default::default(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__unknown_relation_with_inline_type.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__unknown_relation_with_inline_type.snap new file mode 100644 index 00000000..27822281 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__unknown_relation_with_inline_type.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl TestSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + Ok(Self { + id: model.id, + weird: Default::default(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__wrapped_field.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__wrapped_field.snap new file mode 100644 index 00000000..84e15956 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__wrapped_field.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl TestSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + Ok(Self { id: Some(model.id) }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/generate_type.rs b/crates/vespera_macro/src/schema_macro/generate_type.rs new file mode 100644 index 00000000..40f3b29b --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/generate_type.rs @@ -0,0 +1,774 @@ +//! `schema_type!` code generation. +//! +//! Hosts `generate_schema_type_code` - the orchestrator that turns a +//! `SchemaTypeInput` (parsed `schema_type!` invocation) into the generated +//! struct, `From`/`from_model` impls, inline circular types, and metadata. + +use std::collections::HashMap; + +use proc_macro2::TokenStream; +use quote::quote; + +use super::defaults::generate_sea_orm_default_attrs; +use super::file_cache; +use super::file_lookup::find_struct_from_path; +use super::from_model::generate_from_model_with_relations; +use super::inline_types::{ + generate_inline_relation_type, generate_inline_relation_type_no_relations, + generate_inline_type_definition, +}; +use super::input::{PartialMode, SchemaTypeInput}; +use super::same_file_override::maybe_generate_same_file_relation_override; +use super::seaorm::{ + RelationFieldInfo, convert_relation_type_to_schema_with_info, convert_type_with_chrono, + extract_sea_orm_default_value, has_sea_orm_primary_key, +}; +use super::transformation::{ + build_omit_set, build_partial_config, build_pick_set, build_rename_map, determine_rename_all, + extract_doc_attrs, extract_field_serde_attrs, extract_form_data_attrs, + extract_serde_attrs_without_rename_all, filter_out_serde_rename, should_skip_field, + should_wrap_in_option, +}; +use super::type_utils::{ + extract_module_path, extract_type_name, is_option_type, is_qualified_path, is_seaorm_model, + is_seaorm_relation_type, +}; +use super::validation::{ + extract_source_field_names, validate_omit_fields, validate_partial_fields, + validate_pick_fields, validate_rename_fields, +}; +use crate::metadata::StructMetadata; +use crate::parser::{extract_field_rename, strip_raw_prefix_owned}; + +/// Generate a new struct type from an existing type with field filtering +/// +/// Returns (`TokenStream`, Option) where the metadata is returned +/// when a custom `name` is provided (for direct registration in `SCHEMA_STORAGE`). +#[allow(clippy::too_many_lines)] +pub fn generate_schema_type_code( + input: &SchemaTypeInput, + schema_storage: &HashMap, +) -> Result<(TokenStream, Option), syn::Error> { + // Extract type name from the source Type + let source_type_name = extract_type_name(&input.source_type)?; + + // Extract the module path for resolving relative paths in relation types + // This may be empty for simple names like `Model` - will be overridden below if found from file + let mut source_module_path = extract_module_path(&input.source_type); + + // Find struct definition - check SCHEMA_STORAGE first (no file I/O), + // fall back to file lookup for types not registered (e.g., SeaORM Model). + let struct_def_owned: StructMetadata; + let schema_name_hint = input.schema_name.as_deref(); + let struct_def = if is_qualified_path(&input.source_type) { + // Qualified path: try storage first (avoids parse_file for Schema-derived types), + // then file lookup for non-Schema types (e.g., SeaORM Model) + if let Some(found) = schema_storage.get(&source_type_name) { + found + } else if let Some((found, module_path)) = + find_struct_from_path(&input.source_type, schema_name_hint) + { + struct_def_owned = found; + // Use the module path from file lookup for qualified paths + // The file lookup derives module path from actual file location, which is more accurate + // for resolving relative paths like `super::user::Entity` + source_module_path = module_path; + &struct_def_owned + } else { + return Err(syn::Error::new_spanned( + &input.source_type, + format!( + "type `{source_type_name}` not found. Either:\n\ + 1. Use #[derive(Schema)] in the same file\n\ + 2. Use full module path like `crate::models::memo::Model` to reference a struct from another file" + ), + )); + } + } else { + // Simple name: try storage first (for same-file structs), then file lookup with schema name hint + if let Some(found) = schema_storage.get(&source_type_name) { + found + } else if let Some((found, module_path)) = + find_struct_from_path(&input.source_type, schema_name_hint) + { + struct_def_owned = found; + // For simple names, we MUST use the inferred module path from the file location + // This is crucial for resolving relative paths like `super::user::Entity` + source_module_path = module_path; + &struct_def_owned + } else { + return Err(syn::Error::new_spanned( + &input.source_type, + format!( + "type `{source_type_name}` not found. Either:\n\ + 1. Use #[derive(Schema)] in the same file\n\ + 2. Use full module path like `crate::models::memo::Model` to reference a struct from another file\n\ + 3. If using `name = \"XxxSchema\"`, ensure the file name matches (e.g., xxx.rs)" + ), + )); + } + }; + + // Parse the struct definition + let parsed_struct: syn::ItemStruct = file_cache::parse_struct_cached(&struct_def.definition) + .map_err(|e| { + syn::Error::new_spanned( + &input.source_type, + format!("failed to parse struct definition for `{source_type_name}`: {e}"), + ) + })?; + + // Extract all field names from source struct for validation + // Include relation fields since they can be converted to Schema types + let source_field_names = extract_source_field_names(&parsed_struct); + + // Validate all field references exist in source struct + validate_pick_fields( + input.pick.as_ref(), + &source_field_names, + &input.source_type, + &source_type_name, + )?; + validate_omit_fields( + input.omit.as_ref(), + &source_field_names, + &input.source_type, + &source_type_name, + )?; + validate_rename_fields( + input.rename.as_ref(), + &source_field_names, + &input.source_type, + &source_type_name, + )?; + let partial_fields_to_validate = match &input.partial { + Some(PartialMode::Fields(fields)) => Some(fields), + _ => None, + }; + validate_partial_fields( + partial_fields_to_validate, + &source_field_names, + &input.source_type, + &source_type_name, + )?; + + // Build filter sets and rename map + let omit_set = build_omit_set(input.omit.as_ref()); + let pick_set = build_pick_set(input.pick.as_ref()); + let (partial_all, partial_set) = build_partial_config(&input.partial); + let rename_map = build_rename_map(input.rename.as_ref()); + + // Extract serde attributes from source struct, excluding rename_all (we'll handle it separately) + let serde_attrs_without_rename_all = + extract_serde_attrs_without_rename_all(&parsed_struct.attrs); + + // Extract doc comments from source struct to carry over to generated struct + let struct_doc_attrs = extract_doc_attrs(&parsed_struct.attrs); + + // Determine the effective rename_all strategy + let effective_rename_all = + determine_rename_all(input.rename_all.as_ref(), &parsed_struct.attrs); + + // Check if source is a SeaORM Model + let is_source_seaorm_model = is_seaorm_model(&parsed_struct); + + // Generate new struct with filtered fields + let new_type_name = &input.new_type; + let mut field_tokens = Vec::new(); + // Track field mappings for From impl: (new_field_ident, source_field_ident, wrapped_in_option, is_relation) + let mut field_mappings: Vec<(syn::Ident, syn::Ident, bool, bool)> = Vec::new(); + // Track relation field info for from_model generation + let mut relation_fields: Vec = Vec::new(); + // Track inline types that need to be generated for circular relations + let mut inline_type_definitions: Vec = Vec::new(); + // Track default value functions generated from sea_orm(default_value) + let mut default_functions: Vec = Vec::new(); + // Track same-file relation override helpers + let mut relation_override_helpers: Vec = Vec::new(); + + if let syn::Fields::Named(fields_named) = &parsed_struct.fields { + for field in &fields_named.named { + let rust_field_name = field.ident.as_ref().map_or_else( + || "unknown".to_string(), + |i| strip_raw_prefix_owned(i.to_string()), + ); + + // Apply omit/pick filters + if should_skip_field(&rust_field_name, &omit_set, &pick_set) { + continue; + } + + // Apply omit_default: skip fields with sea_orm(default_value) or sea_orm(primary_key) + if input.omit_default + && (extract_sea_orm_default_value(&field.attrs).is_some() + || has_sea_orm_primary_key(&field.attrs)) + { + continue; + } + + // Check if this is a SeaORM relation type + let is_relation = is_seaorm_relation_type(&field.ty); + + // In multipart mode, skip ALL relation fields (multipart forms can't represent nested objects) + if input.multipart && is_relation { + continue; + } + + // Get field components, applying partial wrapping if needed + let original_ty = &field.ty; + let should_wrap_option = should_wrap_in_option( + &rust_field_name, + partial_all, + &partial_set, + is_option_type(original_ty), + is_relation, + ); + + // Determine field type: convert relation types to Schema types + let (field_ty, relation_info): (Box, Option) = + if is_relation { + // Convert HasOne/HasMany/BelongsTo to Schema type + if let Some((converted, mut rel_info)) = + convert_relation_type_to_schema_with_info( + original_ty, + &field.attrs, + &parsed_struct, + &source_module_path, + field.ident.clone().unwrap(), + ) + { + // NEW RULE: HasMany (reverse references) are excluded by default + // They can only be included via explicit `pick` + if rel_info.relation_type == "HasMany" { + // HasMany is only included if explicitly picked + if !pick_set.contains(&rust_field_name) { + continue; + } + // When HasMany IS picked, generate inline type with ALL relations stripped + if let Some(inline_type) = generate_inline_relation_type_no_relations( + new_type_name, + &rel_info, + &source_module_path, + input.schema_name.as_deref(), + ) { + let inline_type_def = generate_inline_type_definition(&inline_type); + inline_type_definitions.push(inline_type_def); + + let inline_type_name = &inline_type.type_name; + let included_fields: Vec = inline_type + .fields + .iter() + .map(|f| f.name.to_string()) + .collect(); + + rel_info.inline_type_info = + Some((inline_type.type_name.clone(), included_fields)); + + let inline_field_ty = quote! { Vec<#inline_type_name> }; + (Box::new(inline_field_ty), Some(rel_info)) + } else { + continue; + } + } else { + // BelongsTo/HasOne: Include by default + if input.add.is_some() + && let Some((override_field_ty, helper_tokens)) = + maybe_generate_same_file_relation_override( + new_type_name, + &rust_field_name, + &rel_info, + schema_storage, + )? + { + relation_override_helpers.push(helper_tokens); + (Box::new(override_field_ty), Some(rel_info)) + } else + // Check for circular references and potentially use inline type + if let Some(inline_type) = generate_inline_relation_type( + new_type_name, + &rel_info, + &source_module_path, + input.schema_name.as_deref(), + ) { + // Generate inline type definition + let inline_type_def = generate_inline_type_definition(&inline_type); + inline_type_definitions.push(inline_type_def); + + // Use inline type instead of direct schema reference + let inline_type_name = &inline_type.type_name; + let circular_fields: Vec = inline_type + .fields + .iter() + .map(|f| f.name.to_string()) + .collect(); + + // Store inline type info + rel_info.inline_type_info = + Some((inline_type.type_name.clone(), circular_fields)); + + // Generate field type using inline type + let inline_field_ty = if rel_info.is_optional { + quote! { Option> } + } else { + quote! { Box<#inline_type_name> } + }; + + (Box::new(inline_field_ty), Some(rel_info)) + } else { + // No circular refs, use original schema path + (Box::new(converted), Some(rel_info)) + } + } + } else { + // Fallback: skip if conversion fails + continue; + } + } else { + // Convert SeaORM datetime types to chrono equivalents + // Also resolves local types to absolute paths + let converted_ty = convert_type_with_chrono(original_ty, &source_module_path); + if should_wrap_option { + (Box::new(quote! { Option<#converted_ty> }), None) + } else { + (Box::new(converted_ty), None) + } + }; + + // Collect relation info — `.extend(...)` keeps the push site + // out of an explicit closure so the coverage tracker + // attributes the call to this source line. + relation_fields.extend(relation_info); + let vis: &syn::Visibility = &field.vis; + let source_field_ident: syn::Ident = field.ident.clone().unwrap(); + + // Extract doc attributes to carry over comments to the generated struct + let doc_attrs = extract_doc_attrs(&field.attrs); + + if input.multipart { + // Multipart mode: emit form_data attrs, suppress serde attrs + let form_data_attrs = extract_form_data_attrs(&field.attrs); + + // Check if field should be renamed (rename still applies to Rust field names) + if let Some(new_name) = rename_map.get(&rust_field_name) { + let new_field_ident = + syn::Ident::new(new_name, field.ident.as_ref().unwrap().span()); + + field_tokens.push(quote! { + #(#doc_attrs)* + #(#form_data_attrs)* + #vis #new_field_ident: #field_ty + }); + + field_mappings.push(( + new_field_ident, + source_field_ident, + should_wrap_option, + is_relation, + )); + } else { + let field_ident = field.ident.clone().unwrap(); + + field_tokens.push(quote! { + #(#doc_attrs)* + #(#form_data_attrs)* + #vis #field_ident: #field_ty + }); + + field_mappings.push(( + field_ident.clone(), + field_ident, + should_wrap_option, + is_relation, + )); + } + } else { + // Normal (serde) mode: emit serde attrs + // Filter field attributes: keep serde and doc attributes, remove sea_orm and others + // This is important when using schema_type! with models from other files + // that may have ORM-specific attributes we don't want in the generated struct + let serde_field_attrs = extract_field_serde_attrs(&field.attrs); + + // Generate serde default + schema(default) from sea_orm(default_value) or primary_key + // Handles literal defaults, SQL function defaults, and implicit auto-increment + let (serde_default_attr, schema_default_attr): ( + proc_macro2::TokenStream, + proc_macro2::TokenStream, + ) = generate_sea_orm_default_attrs( + &field.attrs, + new_type_name, + &rust_field_name, + original_ty, + &field_ty, + should_wrap_option || is_option_type(original_ty), + &mut default_functions, + ); + + // Check if field should be renamed + if let Some(new_name) = rename_map.get(&rust_field_name) { + // Create new identifier for the field + let new_field_ident: syn::Ident = + syn::Ident::new(new_name, field.ident.as_ref().unwrap().span()); + + // Filter out serde(rename) attributes from the serde attrs + let filtered_attrs = filter_out_serde_rename(&serde_field_attrs); + + // Determine the JSON name: use existing serde(rename) if present, otherwise rust field name + let json_name = extract_field_rename(&field.attrs) + .unwrap_or_else(|| rust_field_name.clone()); + + field_tokens.push(quote! { + #(#doc_attrs)* + #(#filtered_attrs)* + #serde_default_attr + #schema_default_attr + #[serde(rename = #json_name)] + #vis #new_field_ident: #field_ty + }); + + // Track mapping: new field name <- source field name + field_mappings.push(( + new_field_ident, + source_field_ident, + should_wrap_option, + is_relation, + )); + } else { + // No rename, keep field with serde and doc attrs + let field_ident = field.ident.clone().unwrap(); + + field_tokens.push(quote! { + #(#doc_attrs)* + #(#serde_field_attrs)* + #serde_default_attr + #schema_default_attr + #vis #field_ident: #field_ty + }); + + // Track mapping: same name + field_mappings.push(( + field_ident.clone(), + field_ident, + should_wrap_option, + is_relation, + )); + } + } + } + } + + // Add new fields from `add` parameter + for (field_name, field_ty) in input.add.iter().flatten() { + let field_ident: syn::Ident = syn::Ident::new(field_name, proc_macro2::Span::call_site()); + field_tokens.push(quote! { + pub #field_ident: #field_ty + }); + } + + // Build derive list + // In multipart mode, force clone = false (FieldData doesn't implement Clone) + let derive_clone: bool = if input.multipart { + false + } else { + input.derive_clone + }; + let clone_derive: proc_macro2::TokenStream = if derive_clone { + quote! { Clone, } + } else { + quote! {} + }; + + // Conditionally include Schema derive based on ignore_schema flag + // Also generate #[schema(name = "...")] attribute if custom name is provided AND Schema is derived + let schema_derive: proc_macro2::TokenStream; + let schema_name_attr: proc_macro2::TokenStream; + if input.ignore_schema { + schema_derive = quote! {}; + schema_name_attr = quote! {}; + } else if let Some(ref name) = input.schema_name { + schema_derive = quote! { vespera::Schema }; + schema_name_attr = quote! { #[schema(name = #name)] }; + } else { + schema_derive = quote! { vespera::Schema }; + schema_name_attr = quote! {}; + } + + // Check if there are any relation fields + let has_relation_fields = field_mappings.iter().any(|(_, _, _, is_rel)| *is_rel); + + // In multipart mode, skip From and from_model impls entirely + let source_type: &syn::Type = &input.source_type; + let (from_impl, from_model_impl) = if input.multipart { + (quote! {}, quote! {}) + } else { + // Generate From impl only if: + // 1. `add` is not used (can't auto-populate added fields) + // 2. There are no relation fields (relation fields don't exist on source Model) + let from_impl = if input.add.is_none() && !has_relation_fields { + let field_assignments: Vec<_> = field_mappings + .iter() + .map(|(new_ident, source_ident, wrapped, _is_relation)| { + if *wrapped { + quote! { #new_ident: Some(source.#source_ident) } + } else { + quote! { #new_ident: source.#source_ident } + } + }) + .collect(); + + quote! { + impl From<#source_type> for #new_type_name { + fn from(source: #source_type) -> Self { + Self { + #(#field_assignments),* + } + } + } + } + } else { + quote! {} + }; + + // Generate from_model impl for SeaORM Models WITH relations + // - No relations: Use `From` trait (generated above) + // - Has relations: async fn from_model(model: Model, db: &DatabaseConnection) -> Result + let from_model_impl = + if is_source_seaorm_model && input.add.is_none() && has_relation_fields { + generate_from_model_with_relations( + new_type_name, + source_type, + &field_mappings, + &relation_fields, + &source_module_path, + schema_storage, + ) + } else { + quote! {} + }; + + (from_impl, from_model_impl) + }; + + // Generate the new struct (with inline types for circular relations first) + let generated_tokens: proc_macro2::TokenStream = if input.multipart { + // Multipart mode: derive Multipart instead of serde + // Emit #[serde(rename_all = ...)] so Multipart applies the rename at runtime + // AND Schema derive reads it via extract_rename_all() fallback for OpenAPI field naming + quote! { + #(#inline_type_definitions)* + + #(#struct_doc_attrs)* + #[derive(vespera::Multipart, #clone_derive #schema_derive)] + #schema_name_attr + #[serde(rename_all = #effective_rename_all)] + pub struct #new_type_name { + #(#field_tokens),* + } + } + } else { + // Normal serde mode + quote! { + // Inline types for circular relation references + #(#inline_type_definitions)* + + // Same-file relation override helpers + #(#relation_override_helpers)* + + // Default value functions for sea_orm(default_value) fields + #(#default_functions)* + + #(#struct_doc_attrs)* + #[derive(serde::Serialize, serde::Deserialize, #clone_derive #schema_derive)] + #schema_name_attr + #[serde(rename_all = #effective_rename_all)] + #(#serde_attrs_without_rename_all)* + pub struct #new_type_name { + #(#field_tokens),* + } + + #from_impl + #from_model_impl + } + }; + + // If custom name is provided, create metadata for direct registration + // This ensures the schema appears in OpenAPI even when `ignore` is set + let metadata = input.schema_name.as_ref().map(|custom_name| { + // Build struct definition string for metadata (without derives/attrs for parsing) + let struct_def = quote! { + #[serde(rename_all = #effective_rename_all)] + #(#serde_attrs_without_rename_all)* + pub struct #new_type_name { + #(#field_tokens),* + } + }; + StructMetadata::new(custom_name.clone(), struct_def.to_string()) + }); + + Ok((generated_tokens, metadata)) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + + fn create_test_struct_metadata(name: &str, definition: &str) -> StructMetadata { + StructMetadata::new(name.to_string(), definition.to_string()) + } + + fn to_storage(items: Vec) -> HashMap { + items.into_iter().map(|s| (s.name.clone(), s)).collect() + } + + #[test] + fn test_generate_schema_type_code_multipart_with_add_and_custom_name() { + let storage = to_storage(vec![create_test_struct_metadata( + "Upload", + "pub struct Upload { pub id: i32, pub name: String }", + )]); + + let tokens = quote!( + UploadForm from Upload, + multipart, + name = "UploadFormSchema", + add = [("extra": String)] + ); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("vespera :: Multipart")); + assert!(output.contains("extra")); + assert!(output.contains("UploadFormSchema")); + assert_eq!(metadata.unwrap().name, "UploadFormSchema"); + } + // ============================================================ + // Tests for multipart mode + // ============================================================ + + #[test] + fn test_generate_schema_type_code_multipart_basic() { + // Tests: multipart mode generates Multipart derive, suppresses From impl + let storage = to_storage(vec![create_test_struct_metadata( + "UploadRequest", + "pub struct UploadRequest { pub name: String, pub description: Option }", + )]); + + let tokens = quote!(PatchUpload from UploadRequest, multipart); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should derive Multipart + assert!(output.contains("Multipart")); + // Should NOT have From impl (multipart suppresses it) + assert!(!output.contains("impl From")); + // Should have the struct fields + assert!(output.contains("name")); + assert!(output.contains("description")); + } + + #[test] + fn test_generate_schema_type_code_multipart_with_rename() { + // Tests: multipart mode with field rename + let storage = to_storage(vec![create_test_struct_metadata( + "UploadRequest", + "pub struct UploadRequest { pub name: String, pub file_path: String }", + )]); + + let tokens = quote!(RenamedUpload from UploadRequest, multipart, rename = [("file_path", "document_path")]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should derive Multipart + assert!(output.contains("Multipart")); + // Should have renamed field + assert!(output.contains("document_path")); + // Original name should NOT appear as field + assert!(!output.contains("file_path")); + } + + #[test] + fn test_generate_schema_type_code_multipart_with_form_data_attrs() { + // Tests: multipart mode preserves #[form_data] attributes from source + let storage = to_storage(vec![create_test_struct_metadata( + "UploadRequest", + r#"pub struct UploadRequest { + pub name: String, + #[form_data(limit = "10MiB")] + pub file: String + }"#, + )]); + + let tokens = quote!(PatchUpload from UploadRequest, multipart); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should preserve form_data attributes + assert!(output.contains("form_data")); + assert!(output.contains("limit")); + } + + #[test] + fn test_generate_schema_type_code_multipart_skips_relations() { + // Tests: multipart mode skips relation fields + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "memos")] + pub struct Model { + pub id: i32, + pub title: String, + pub user: BelongsTo + }"#, + )]); + + let tokens = quote!(MemoUpload from Model, multipart); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Relation field should be skipped in multipart mode + assert!(!output.contains("user")); + // Regular fields should be present + assert!(output.contains("id")); + assert!(output.contains("title")); + // Should derive Multipart + assert!(output.contains("Multipart")); + } + + #[test] + fn test_generate_schema_type_code_multipart_partial() { + // Coverage for multipart + partial combination + let storage = to_storage(vec![create_test_struct_metadata( + "UploadRequest", + "pub struct UploadRequest { pub name: String, pub tags: String }", + )]); + + let tokens = quote!(PatchUpload from UploadRequest, multipart, partial); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should derive Multipart + assert!(output.contains("Multipart")); + // Fields should be wrapped in Option (partial) + assert!(output.contains("Option")); + // Should NOT have From impl + assert!(!output.contains("impl From")); + } +} diff --git a/crates/vespera_macro/src/schema_macro/inline_types.rs b/crates/vespera_macro/src/schema_macro/inline_types.rs index cbf13496..53a6b007 100644 --- a/crates/vespera_macro/src/schema_macro/inline_types.rs +++ b/crates/vespera_macro/src/schema_macro/inline_types.rs @@ -261,424 +261,275 @@ pub fn generate_inline_type_definition(inline_type: &InlineRelationType) -> Toke #[cfg(test)] mod tests { + use rstest::rstest; use serial_test::serial; use super::*; - #[test] - fn test_generate_inline_type_definition() { - let inline_type = InlineRelationType { - type_name: syn::Ident::new("UserInline", proc_macro2::Span::call_site()), - fields: vec![ - InlineField { - name: syn::Ident::new("id", proc_macro2::Span::call_site()), - ty: quote!(i32), - attrs: vec![], - }, - InlineField { - name: syn::Ident::new("name", proc_macro2::Span::call_site()), - ty: quote!(String), - attrs: vec![], - }, - ], - rename_all: "camelCase".to_string(), - }; + // ── Test support ───────────────────────────────────────────────────── - let tokens = generate_inline_type_definition(&inline_type); - let output = tokens.to_string(); + /// Render generated item tokens as formatted Rust source so snapshots + /// review like real code instead of a single token-soup line. + fn pretty(tokens: &proc_macro2::TokenStream) -> String { + let file: syn::File = + syn::parse2(tokens.clone()).expect("generated tokens must parse as Rust items"); + prettyplease::unparse(&file) + } - assert!(output.contains("pub struct UserInline")); - assert!(output.contains("pub id : i32")); - assert!(output.contains("pub name : String")); - assert!(output.contains("serde :: Serialize")); - assert!(output.contains("serde :: Deserialize")); - assert!(output.contains("vespera :: Schema")); - assert!(output.contains("camelCase")); + /// Compact [`InlineField`] constructor for table-driven cases. + fn field(name: &str, ty: proc_macro2::TokenStream, attrs: Vec) -> InlineField { + InlineField { + name: syn::Ident::new(name, proc_macro2::Span::call_site()), + ty, + attrs, + } } - #[test] - fn test_generate_inline_type_definition_with_attrs() { - let inline_type = InlineRelationType { - type_name: syn::Ident::new("TestType", proc_macro2::Span::call_site()), - fields: vec![InlineField { - name: syn::Ident::new("field", proc_macro2::Span::call_site()), - ty: quote!(String), - attrs: vec![syn::parse_quote!(#[serde(rename = "renamed")])], - }], - rename_all: "snake_case".to_string(), - }; + /// Compact [`InlineRelationType`] constructor for table-driven cases. + fn inline(name: &str, rename_all: &str, fields: Vec) -> InlineRelationType { + InlineRelationType { + type_name: syn::Ident::new(name, proc_macro2::Span::call_site()), + fields, + rename_all: rename_all.to_string(), + } + } - let tokens = generate_inline_type_definition(&inline_type); - let output = tokens.to_string(); + /// Compact [`RelationFieldInfo`] constructor — the original tests + /// repeated this 10-line struct literal a dozen times. + fn rel( + field_name: &str, + relation_type: &str, + schema_path: proc_macro2::TokenStream, + ) -> RelationFieldInfo { + RelationFieldInfo { + field_name: syn::Ident::new(field_name, proc_macro2::Span::call_site()), + relation_type: relation_type.to_string(), + schema_path, + is_optional: false, + inline_type_info: None, + relation_enum: None, + fk_column: None, + via_rel: None, + } + } - assert!(output.contains("TestType")); - assert!(output.contains("snake_case")); + /// Sorted field names of a generated inline type — list equality + /// asserts both inclusions and exclusions in one comparison. + fn field_names(inline_type: &InlineRelationType) -> Vec { + let mut names: Vec = inline_type + .fields + .iter() + .map(|f| f.name.to_string()) + .collect(); + names.sort(); + names } - #[test] - fn test_generate_inline_type_definition_empty_fields() { - let inline_type = InlineRelationType { - type_name: syn::Ident::new("EmptyType", proc_macro2::Span::call_site()), - fields: vec![], - rename_all: "camelCase".to_string(), - }; + const MEMO_MODULE: [&str; 3] = ["crate", "models", "memo"]; - let tokens = generate_inline_type_definition(&inline_type); - let output = tokens.to_string(); + fn module_path(segments: &[&str]) -> Vec { + segments.iter().map(ToString::to_string).collect() + } - assert!(output.contains("pub struct EmptyType")); - assert!(output.contains("Clone")); - assert!(output.contains("vespera :: Schema")); + /// Run `body` with `CARGO_MANIFEST_DIR` pointing at `dir`, restoring + /// the original value afterwards. + fn with_manifest_dir(dir: &std::path::Path, body: impl FnOnce() -> T) -> T { + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + // SAFETY: callers are #[serial] tests — no concurrent env access. + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", dir) }; + let result = body(); + // SAFETY: same as above. + unsafe { + match original { + Some(value) => std::env::set_var("CARGO_MANIFEST_DIR", value), + None => std::env::remove_var("CARGO_MANIFEST_DIR"), + } + } + result } - #[test] - fn test_generate_inline_type_definition_multiple_attrs() { - let inline_type = InlineRelationType { - type_name: syn::Ident::new("MultiAttrType", proc_macro2::Span::call_site()), - fields: vec![InlineField { - name: syn::Ident::new("field", proc_macro2::Span::call_site()), - ty: quote!(String), - attrs: vec![ + // ── generate_inline_type_definition: snapshot the full output ─────── + // + // The generated struct IS the contract — snapshotting the whole + // pretty-printed item locks derives, serde attributes, field types, + // and rename_all in one reviewable artifact, instead of probing a + // handful of `contains` substrings around unverified output. + + #[rstest] + #[case::two_plain_fields_camel_case( + "two_plain_fields_camel_case", + inline( + "UserInline", + "camelCase", + vec![field("id", quote!(i32), vec![]), field("name", quote!(String), vec![])], + ) + )] + #[case::field_attr_rename_snake_case( + "field_attr_rename_snake_case", + inline( + "TestType", + "snake_case", + vec![field( + "field", + quote!(String), + vec![syn::parse_quote!(#[serde(rename = "renamed")])], + )], + ) + )] + #[case::empty_fields("empty_fields", inline("EmptyType", "camelCase", vec![]))] + #[case::multiple_field_attrs_pascal_case( + "multiple_field_attrs_pascal_case", + inline( + "MultiAttrType", + "PascalCase", + vec![field( + "field", + quote!(String), + vec![ syn::parse_quote!(#[serde(default)]), syn::parse_quote!(#[serde(skip_serializing_if = "Option::is_none")]), ], - }], - rename_all: "PascalCase".to_string(), - }; - - let tokens = generate_inline_type_definition(&inline_type); - let output = tokens.to_string(); - - assert!(output.contains("MultiAttrType")); - assert!(output.contains("PascalCase")); - assert!(output.contains("default")); - } - - #[test] - fn test_generate_inline_type_definition_complex_type() { - let inline_type = InlineRelationType { - type_name: syn::Ident::new("ComplexType", proc_macro2::Span::call_site()), - fields: vec![ - InlineField { - name: syn::Ident::new("id", proc_macro2::Span::call_site()), - ty: quote!(i32), - attrs: vec![], - }, - InlineField { - name: syn::Ident::new("tags", proc_macro2::Span::call_site()), - ty: quote!(Vec), - attrs: vec![], - }, - InlineField { - name: syn::Ident::new("metadata", proc_macro2::Span::call_site()), - ty: quote!(Option>), - attrs: vec![], - }, + )], + ) + )] + #[case::complex_field_types( + "complex_field_types", + inline( + "ComplexType", + "camelCase", + vec![ + field("id", quote!(i32), vec![]), + field("tags", quote!(Vec), vec![]), + field( + "metadata", + quote!(Option>), + vec![], + ), ], - rename_all: "camelCase".to_string(), - }; - - let tokens = generate_inline_type_definition(&inline_type); - let output = tokens.to_string(); - - assert!(output.contains("pub struct ComplexType")); - assert!(output.contains("pub id : i32")); - assert!(output.contains("Vec < String >")); - assert!(output.contains("Option <")); + ) + )] + #[case::doc_attribute( + "doc_attribute", + inline( + "DocType", + "camelCase", + vec![field( + "documented_field", + quote!(String), + vec![syn::parse_quote!(#[doc = "This is a documented field"])], + )], + ) + )] + fn generate_inline_type_definition_snapshot( + #[case] snapshot_name: &str, + #[case] inline_type: InlineRelationType, + ) { + // Explicit snapshot name per case: insta's auto-naming counts + // duplicate assertions per *function* in execution order, which + // shuffles across parallel rstest cases. + insta::assert_snapshot!( + snapshot_name, + pretty(&generate_inline_type_definition(&inline_type)) + ); } #[test] - fn test_inline_field_struct() { - // Test InlineField struct construction - let field = InlineField { - name: syn::Ident::new("test_field", proc_macro2::Span::call_site()), - ty: quote!(Option), - attrs: vec![syn::parse_quote!(#[doc = "Test doc"])], - }; - + fn inline_field_struct_holds_constructor_inputs() { + let field = field( + "test_field", + quote!(Option), + vec![syn::parse_quote!(#[doc = "Test doc"])], + ); assert_eq!(field.name.to_string(), "test_field"); assert!(!field.attrs.is_empty()); } #[test] - fn test_inline_relation_type_struct() { - // Test InlineRelationType struct construction - let inline_type = InlineRelationType { - type_name: syn::Ident::new("TestRelation", proc_macro2::Span::call_site()), - fields: vec![], - rename_all: "SCREAMING_SNAKE_CASE".to_string(), - }; - + fn inline_relation_type_struct_holds_constructor_inputs() { + let inline_type = inline("TestRelation", "SCREAMING_SNAKE_CASE", vec![]); assert_eq!(inline_type.type_name.to_string(), "TestRelation"); assert_eq!(inline_type.rename_all, "SCREAMING_SNAKE_CASE"); assert!(inline_type.fields.is_empty()); } - #[test] - fn test_generate_inline_type_definition_doc_attr() { - let inline_type = InlineRelationType { - type_name: syn::Ident::new("DocType", proc_macro2::Span::call_site()), - fields: vec![InlineField { - name: syn::Ident::new("documented_field", proc_macro2::Span::call_site()), - ty: quote!(String), - attrs: vec![syn::parse_quote!(#[doc = "This is a documented field"])], - }], - rename_all: "camelCase".to_string(), - }; - - let tokens = generate_inline_type_definition(&inline_type); - let output = tokens.to_string(); - - assert!(output.contains("DocType")); - assert!(output.contains("documented_field")); - assert!(output.contains("doc")); - } + // ── generate_inline_relation_type_from_def ────────────────────────── #[test] - fn test_generate_inline_relation_type_from_def_with_circular() { - // Test inline type generation when circular reference exists - let parent_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), - relation_type: "BelongsTo".to_string(), - schema_path: quote!(super::user::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - - // UserSchema has a circular reference back to memo via HasMany + fn from_def_has_many_is_not_circular() { let model_def = r"pub struct Model { pub id: i32, pub name: String, pub memos: HasMany }"; - let result = generate_inline_relation_type_from_def( - &parent_type_name, - &rel_info, - &source_module_path, + &syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()), + &rel("user", "BelongsTo", quote!(super::user::Schema)), + &module_path(&MEMO_MODULE), None, model_def, ); - // HasMany is not considered circular, so should return None - assert!(result.is_none()); + assert!(result.is_none(), "HasMany back-references are not circular"); + } - // Test with BelongsTo instead (which IS considered circular) - let model_def_with_belongs_to = r"pub struct Model { + #[test] + fn from_def_belongs_to_is_circular_and_strips_the_relation() { + let model_def = r"pub struct Model { pub id: i32, pub name: String, pub memo: BelongsTo }"; - let result = generate_inline_relation_type_from_def( - &parent_type_name, - &rel_info, - &source_module_path, + &syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()), + &rel("user", "BelongsTo", quote!(super::user::Schema)), + &module_path(&MEMO_MODULE), None, - model_def_with_belongs_to, - ); - assert!(result.is_some()); + model_def, + ) + .expect("BelongsTo back-reference is circular"); - let inline_type = result.unwrap(); - assert_eq!(inline_type.type_name.to_string(), "MemoSchema_User"); - // Should have id and name fields, but NOT memo (circular) - let field_names: Vec = inline_type - .fields - .iter() - .map(|f| f.name.to_string()) - .collect(); - assert!(field_names.contains(&"id".to_string())); - assert!(field_names.contains(&"name".to_string())); - assert!(!field_names.contains(&"memo".to_string())); + assert_eq!(result.type_name.to_string(), "MemoSchema_User"); + assert_eq!(field_names(&result), ["id", "name"]); } #[test] - fn test_generate_inline_relation_type_from_def_no_circular() { - // Test that None is returned when no circular reference exists - let parent_type_name = syn::Ident::new("TestSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("other", proc_macro2::Span::call_site()), - relation_type: "BelongsTo".to_string(), - schema_path: quote!(super::other::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "test".to_string(), - ]; - - // No circular reference + fn from_def_no_circular_reference_returns_none() { let model_def = r"pub struct Model { pub id: i32, pub name: String }"; - let result = generate_inline_relation_type_from_def( - &parent_type_name, - &rel_info, - &source_module_path, + &syn::Ident::new("TestSchema", proc_macro2::Span::call_site()), + &rel("other", "BelongsTo", quote!(super::other::Schema)), + &module_path(&["crate", "models", "test"]), None, model_def, ); - assert!(result.is_none()); // No circular fields means no inline type needed + assert!(result.is_none(), "no circular fields means no inline type"); } #[test] - fn test_generate_inline_relation_type_from_def_with_schema_name_override() { - let parent_type_name = syn::Ident::new("Schema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), - relation_type: "BelongsTo".to_string(), - schema_path: quote!(super::user::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - + fn from_def_schema_name_override_names_the_inline_type() { let model_def = r"pub struct Model { pub id: i32, pub memo: BelongsTo }"; - - // With schema_name_override let result = generate_inline_relation_type_from_def( - &parent_type_name, - &rel_info, - &source_module_path, + &syn::Ident::new("Schema", proc_macro2::Span::call_site()), + &rel("user", "BelongsTo", quote!(super::user::Schema)), + &module_path(&MEMO_MODULE), Some("MemoSchema"), model_def, - ); - assert!(result.is_some()); - assert_eq!(result.unwrap().type_name.to_string(), "MemoSchema_User"); - } - - #[test] - fn test_generate_inline_relation_type_no_relations_from_def() { - let parent_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("memos", proc_macro2::Span::call_site()), - relation_type: "HasMany".to_string(), - schema_path: quote!(super::memo::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - - // Model with relations that should be stripped - let model_def = r"pub struct Model { - pub id: i32, - pub title: String, - pub user: BelongsTo, - pub comments: HasMany - }"; - - let result = generate_inline_relation_type_no_relations_from_def( - &parent_type_name, - &rel_info, - &[], - None, - model_def, - ); - assert!(result.is_some()); - - let inline_type = result.unwrap(); - assert_eq!(inline_type.type_name.to_string(), "UserSchema_Memos"); - - // Should have id and title, but NOT user or comments (relations) - let field_names: Vec = inline_type - .fields - .iter() - .map(|f| f.name.to_string()) - .collect(); - assert!(field_names.contains(&"id".to_string())); - assert!(field_names.contains(&"title".to_string())); - assert!(!field_names.contains(&"user".to_string())); - assert!(!field_names.contains(&"comments".to_string())); - } - - #[test] - fn test_generate_inline_relation_type_no_relations_from_def_with_skip() { - let parent_type_name = syn::Ident::new("TestSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("items", proc_macro2::Span::call_site()), - relation_type: "HasMany".to_string(), - schema_path: quote!(super::item::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - - // Model with serde(skip) field - let model_def = r"pub struct Model { - pub id: i32, - #[serde(skip)] - pub internal: String, - pub name: String - }"; - - let result = generate_inline_relation_type_no_relations_from_def( - &parent_type_name, - &rel_info, - &[], - None, - model_def, - ); - assert!(result.is_some()); - - let inline_type = result.unwrap(); - let field_names: Vec = inline_type - .fields - .iter() - .map(|f| f.name.to_string()) - .collect(); - assert!(field_names.contains(&"id".to_string())); - assert!(field_names.contains(&"name".to_string())); - assert!(!field_names.contains(&"internal".to_string())); // skipped + ) + .expect("circular reference present"); + assert_eq!(result.type_name.to_string(), "MemoSchema_User"); } #[test] - fn test_generate_inline_relation_type_from_def_invalid_model() { - let parent_type_name = syn::Ident::new("TestSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), - relation_type: "BelongsTo".to_string(), - schema_path: quote!(super::user::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - let source_module_path = vec!["crate".to_string()]; - + fn from_def_invalid_model_source_returns_none() { let result = generate_inline_relation_type_from_def( - &parent_type_name, - &rel_info, - &source_module_path, + &syn::Ident::new("TestSchema", proc_macro2::Span::call_site()), + &rel("user", "BelongsTo", quote!(super::user::Schema)), + &module_path(&["crate"]), None, "invalid rust code", ); @@ -686,26 +537,7 @@ mod tests { } #[test] - fn test_generate_inline_relation_type_from_def_skips_relation_types() { - // Test that relation types (HasOne, HasMany, BelongsTo) are skipped - let parent_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), - relation_type: "BelongsTo".to_string(), - schema_path: quote!(super::user::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - - // Model with circular field AND other relation types that should be skipped + fn from_def_skips_every_relation_typed_field() { let model_def = r"pub struct Model { pub id: i32, pub name: String, @@ -713,53 +545,23 @@ mod tests { pub posts: HasMany, pub profile: HasOne }"; - let result = generate_inline_relation_type_from_def( - &parent_type_name, - &rel_info, - &source_module_path, + &syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()), + &rel("user", "BelongsTo", quote!(super::user::Schema)), + &module_path(&MEMO_MODULE), None, model_def, + ) + .expect("circular reference present"); + assert_eq!( + field_names(&result), + ["id", "name"], + "circular AND non-circular relation fields must all be stripped" ); - assert!(result.is_some()); - - let inline_type = result.unwrap(); - let field_names: Vec = inline_type - .fields - .iter() - .map(|f| f.name.to_string()) - .collect(); - - // Should have id and name - assert!(field_names.contains(&"id".to_string())); - assert!(field_names.contains(&"name".to_string())); - // Should NOT have any relation fields (circular or otherwise) - assert!(!field_names.contains(&"memo".to_string())); // circular - assert!(!field_names.contains(&"posts".to_string())); // HasMany - relation type - assert!(!field_names.contains(&"profile".to_string())); // HasOne - relation type } #[test] - fn test_generate_inline_relation_type_from_def_skips_serde_skip() { - // Test that fields with serde(skip) are skipped - let parent_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), - relation_type: "BelongsTo".to_string(), - schema_path: quote!(super::user::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - - // Model with circular field AND serde(skip) field + fn from_def_skips_serde_skip_fields() { let model_def = r"pub struct Model { pub id: i32, #[serde(skip)] @@ -767,385 +569,103 @@ mod tests { pub name: String, pub memo: BelongsTo }"; - let result = generate_inline_relation_type_from_def( - &parent_type_name, - &rel_info, - &source_module_path, + &syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()), + &rel("user", "BelongsTo", quote!(super::user::Schema)), + &module_path(&MEMO_MODULE), None, model_def, - ); - assert!(result.is_some()); - - let inline_type = result.unwrap(); - let field_names: Vec = inline_type - .fields - .iter() - .map(|f| f.name.to_string()) - .collect(); - - // Should have id and name - assert!(field_names.contains(&"id".to_string())); - assert!(field_names.contains(&"name".to_string())); - // Should NOT have skipped or circular fields - assert!(!field_names.contains(&"internal_cache".to_string())); // serde(skip) - assert!(!field_names.contains(&"memo".to_string())); // circular + ) + .expect("circular reference present"); + assert_eq!(field_names(&result), ["id", "name"]); } #[test] - fn test_generate_inline_relation_type_no_relations_from_def_with_schema_name_override() { - // Test schema_name_override Some branch - let parent_type_name = syn::Ident::new("Schema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("memos", proc_macro2::Span::call_site()), - relation_type: "HasMany".to_string(), - schema_path: quote!(super::memo::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - + fn from_def_converts_datetime_types() { let model_def = r"pub struct Model { pub id: i32, - pub title: String + pub name: String, + pub created_at: DateTimeWithTimeZone, + pub memo: BelongsTo }"; - - // With schema_name_override - let result = generate_inline_relation_type_no_relations_from_def( - &parent_type_name, - &rel_info, - &[], - Some("UserSchema"), + let result = generate_inline_relation_type_from_def( + &syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()), + &rel("user", "BelongsTo", quote!(super::user::Schema)), + &module_path(&MEMO_MODULE), + None, model_def, - ); - assert!(result.is_some()); + ) + .expect("circular reference present"); - let inline_type = result.unwrap(); - // Should use the override name, not the struct name - assert_eq!(inline_type.type_name.to_string(), "UserSchema_Memos"); - } - - // Tests for public functions with file lookup - // These require setting up a temp directory with model files - - #[test] - #[serial] - fn test_generate_inline_relation_type_with_file_lookup() { - use tempfile::TempDir; - - // Create temp directory structure - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create a user.rs file with Model struct that has circular reference - let user_model = r" -pub struct Model { - pub id: i32, - pub name: String, - pub memo: BelongsTo, -} -"; - std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - - // Save original CARGO_MANIFEST_DIR and set to temp dir - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: This is a test that runs single-threaded - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Test generate_inline_relation_type - let parent_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), - relation_type: "BelongsTo".to_string(), - schema_path: quote!(crate::models::user::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - - let result = - generate_inline_relation_type(&parent_type_name, &rel_info, &source_module_path, None); - - // Restore original CARGO_MANIFEST_DIR - // SAFETY: This is a test that runs single-threaded - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - // Verify result - assert!(result.is_some()); - let inline_type = result.unwrap(); - assert_eq!(inline_type.type_name.to_string(), "MemoSchema_User"); - - // Should have id and name, but not memo (circular) - let field_names: Vec = inline_type + let created_at = result .fields .iter() - .map(|f| f.name.to_string()) - .collect(); - assert!(field_names.contains(&"id".to_string())); - assert!(field_names.contains(&"name".to_string())); - assert!(!field_names.contains(&"memo".to_string())); + .find(|f| f.name == "created_at") + .expect("created_at field should exist"); + insta::assert_snapshot!("from_def_created_at_type", created_at.ty.to_string()); } - #[test] - #[serial] - fn test_generate_inline_relation_type_no_relations_with_file_lookup() { - use tempfile::TempDir; - - // Create temp directory structure - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create a memo.rs file with Model struct that has relations - let memo_model = r" -pub struct Model { - pub id: i32, - pub title: String, - pub user: BelongsTo, - pub comments: HasMany, -} -"; - std::fs::write(models_dir.join("memo.rs"), memo_model).unwrap(); - - // Save original CARGO_MANIFEST_DIR and set to temp dir - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: This is a test that runs single-threaded - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Test generate_inline_relation_type_no_relations - let parent_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("memos", proc_macro2::Span::call_site()), - relation_type: "HasMany".to_string(), - schema_path: quote!(crate::models::memo::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - let result = generate_inline_relation_type_no_relations( - &parent_type_name, - &rel_info, - &source_module_path, - None, - ); - - // Restore original CARGO_MANIFEST_DIR - // SAFETY: This is a test that runs single-threaded - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - // Verify result - assert!(result.is_some()); - let inline_type = result.unwrap(); - assert_eq!(inline_type.type_name.to_string(), "UserSchema_Memos"); - - // Should have id and title, but not user or comments (relations) - let field_names: Vec = inline_type - .fields - .iter() - .map(|f| f.name.to_string()) - .collect(); - assert!(field_names.contains(&"id".to_string())); - assert!(field_names.contains(&"title".to_string())); - assert!(!field_names.contains(&"user".to_string())); - assert!(!field_names.contains(&"comments".to_string())); - } + // ── generate_inline_relation_type_no_relations_from_def ───────────── #[test] - #[serial] - fn test_generate_inline_relation_type_file_not_found() { - use tempfile::TempDir; - - // Create temp directory structure without the model file - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - std::fs::create_dir_all(&src_dir).unwrap(); - - // Save original CARGO_MANIFEST_DIR and set to temp dir - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: This is a test that runs single-threaded - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let parent_type_name = syn::Ident::new("TestSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), - relation_type: "BelongsTo".to_string(), - schema_path: quote!(crate::models::nonexistent::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - let source_module_path = vec!["crate".to_string()]; - - let result = - generate_inline_relation_type(&parent_type_name, &rel_info, &source_module_path, None); - - // Restore original CARGO_MANIFEST_DIR - // SAFETY: This is a test that runs single-threaded - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } + fn no_relations_from_def_strips_relations() { + let model_def = r"pub struct Model { + pub id: i32, + pub title: String, + pub user: BelongsTo, + pub comments: HasMany + }"; + let result = generate_inline_relation_type_no_relations_from_def( + &syn::Ident::new("UserSchema", proc_macro2::Span::call_site()), + &rel("memos", "HasMany", quote!(super::memo::Schema)), + &[], + None, + model_def, + ) + .expect("plain fields remain"); - // Should return None when file not found - assert!(result.is_none()); + assert_eq!(result.type_name.to_string(), "UserSchema_Memos"); + assert_eq!(field_names(&result), ["id", "title"]); } #[test] - #[serial] - fn test_generate_inline_relation_type_no_relations_file_not_found() { - use tempfile::TempDir; - - // Create temp directory structure without the model file - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - std::fs::create_dir_all(&src_dir).unwrap(); - - // Save original CARGO_MANIFEST_DIR and set to temp dir - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: This is a test that runs single-threaded - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let parent_type_name = syn::Ident::new("TestSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("items", proc_macro2::Span::call_site()), - relation_type: "HasMany".to_string(), - schema_path: quote!(crate::models::nonexistent::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - - let result = - generate_inline_relation_type_no_relations(&parent_type_name, &rel_info, &[], None); - - // Restore original CARGO_MANIFEST_DIR - // SAFETY: This is a test that runs single-threaded - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - // Should return None when file not found - assert!(result.is_none()); + fn no_relations_from_def_skips_serde_skip_fields() { + let model_def = r"pub struct Model { + pub id: i32, + #[serde(skip)] + pub internal: String, + pub name: String + }"; + let result = generate_inline_relation_type_no_relations_from_def( + &syn::Ident::new("TestSchema", proc_macro2::Span::call_site()), + &rel("items", "HasMany", quote!(super::item::Schema)), + &[], + None, + model_def, + ) + .expect("plain fields remain"); + assert_eq!(field_names(&result), ["id", "name"]); } #[test] - fn test_generate_inline_relation_type_converts_datetime_types() { - // Test that DateTimeWithTimeZone is converted to vespera::chrono::DateTime - let parent_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), - relation_type: "BelongsTo".to_string(), - schema_path: quote!(super::user::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - - // Model with DateTimeWithTimeZone field AND circular reference + fn no_relations_from_def_schema_name_override_names_the_inline_type() { let model_def = r"pub struct Model { pub id: i32, - pub name: String, - pub created_at: DateTimeWithTimeZone, - pub memo: BelongsTo + pub title: String }"; - - let result = generate_inline_relation_type_from_def( - &parent_type_name, - &rel_info, - &source_module_path, - None, + let result = generate_inline_relation_type_no_relations_from_def( + &syn::Ident::new("Schema", proc_macro2::Span::call_site()), + &rel("memos", "HasMany", quote!(super::memo::Schema)), + &[], + Some("UserSchema"), model_def, - ); - assert!(result.is_some()); - - let inline_type = result.unwrap(); - assert_eq!(inline_type.type_name.to_string(), "MemoSchema_User"); - - // Find created_at field and check its type was converted - let created_at_field = inline_type - .fields - .iter() - .find(|f| f.name == "created_at") - .expect("created_at field should exist"); - - let ty_str = created_at_field.ty.to_string(); - // Should be converted to vespera::chrono::DateTime - assert!( - ty_str.contains("vespera :: chrono :: DateTime"), - "DateTimeWithTimeZone should be converted to vespera::chrono::DateTime, got: {ty_str}" - ); - assert!( - ty_str.contains("FixedOffset"), - "Should contain FixedOffset, got: {ty_str}" - ); + ) + .expect("plain fields remain"); + assert_eq!(result.type_name.to_string(), "UserSchema_Memos"); } #[test] - fn test_generate_inline_relation_type_no_relations_converts_datetime_types() { - // Test that DateTimeWithTimeZone is converted in no_relations variant too - let parent_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("memos", proc_macro2::Span::call_site()), - relation_type: "HasMany".to_string(), - schema_path: quote!(super::memo::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - - // Model with DateTimeWithTimeZone field + fn no_relations_from_def_converts_datetime_types() { let model_def = r"pub struct Model { pub id: i32, pub title: String, @@ -1153,46 +673,140 @@ pub struct Model { pub updated_at: Option, pub user: BelongsTo }"; - let result = generate_inline_relation_type_no_relations_from_def( - &parent_type_name, - &rel_info, + &syn::Ident::new("UserSchema", proc_macro2::Span::call_site()), + &rel("memos", "HasMany", quote!(super::memo::Schema)), &[], None, model_def, + ) + .expect("plain fields remain"); + + let ty_of = |name: &str| { + result + .fields + .iter() + .find(|f| f.name == name) + .unwrap_or_else(|| panic!("{name} field should exist")) + .ty + .to_string() + }; + insta::assert_snapshot!( + "no_relations_datetime_types", + format!( + "created_at: {}\nupdated_at: {}", + ty_of("created_at"), + ty_of("updated_at"), + ) ); - assert!(result.is_some()); + } - let inline_type = result.unwrap(); + // ── File-lookup variants (CARGO_MANIFEST_DIR + temp project) ──────── - // Find created_at field and check its type was converted - let created_at_field = inline_type - .fields - .iter() - .find(|f| f.name == "created_at") - .expect("created_at field should exist"); + #[test] + #[serial] + fn file_lookup_generates_inline_type_for_circular_model() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let models_dir = temp_dir.path().join("src").join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + std::fs::write( + models_dir.join("user.rs"), + r" + pub struct Model { + pub id: i32, + pub name: String, + pub memo: BelongsTo, + } + ", + ) + .unwrap(); + + let result = with_manifest_dir(temp_dir.path(), || { + generate_inline_relation_type( + &syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()), + &rel("user", "BelongsTo", quote!(crate::models::user::Schema)), + &module_path(&MEMO_MODULE), + None, + ) + }) + .expect("circular reference present"); - let ty_str = created_at_field.ty.to_string(); - assert!( - ty_str.contains("vespera :: chrono :: DateTime"), - "DateTimeWithTimeZone should be converted, got: {ty_str}" - ); + assert_eq!(result.type_name.to_string(), "MemoSchema_User"); + assert_eq!(field_names(&result), ["id", "name"]); + } - // Also check Option - let updated_at_field = inline_type - .fields - .iter() - .find(|f| f.name == "updated_at") - .expect("updated_at field should exist"); + #[test] + #[serial] + fn file_lookup_no_relations_strips_relations() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let models_dir = temp_dir.path().join("src").join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + std::fs::write( + models_dir.join("memo.rs"), + r" + pub struct Model { + pub id: i32, + pub title: String, + pub user: BelongsTo, + pub comments: HasMany, + } + ", + ) + .unwrap(); + + let result = with_manifest_dir(temp_dir.path(), || { + generate_inline_relation_type_no_relations( + &syn::Ident::new("UserSchema", proc_macro2::Span::call_site()), + &rel("memos", "HasMany", quote!(crate::models::memo::Schema)), + &module_path(&["crate", "models", "user"]), + None, + ) + }) + .expect("plain fields remain"); - let updated_ty_str = updated_at_field.ty.to_string(); - assert!( - updated_ty_str.contains("Option"), - "Should be Option type, got: {updated_ty_str}" - ); - assert!( - updated_ty_str.contains("vespera :: chrono :: DateTime"), - "Option should be converted, got: {updated_ty_str}" - ); + assert_eq!(result.type_name.to_string(), "UserSchema_Memos"); + assert_eq!(field_names(&result), ["id", "title"]); + } + + #[test] + #[serial] + fn file_lookup_missing_model_file_returns_none() { + let temp_dir = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(temp_dir.path().join("src")).unwrap(); + + let result = with_manifest_dir(temp_dir.path(), || { + generate_inline_relation_type( + &syn::Ident::new("TestSchema", proc_macro2::Span::call_site()), + &rel( + "user", + "BelongsTo", + quote!(crate::models::nonexistent::Schema), + ), + &module_path(&["crate"]), + None, + ) + }); + assert!(result.is_none()); + } + + #[test] + #[serial] + fn file_lookup_no_relations_missing_model_file_returns_none() { + let temp_dir = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(temp_dir.path().join("src")).unwrap(); + + let result = with_manifest_dir(temp_dir.path(), || { + generate_inline_relation_type_no_relations( + &syn::Ident::new("TestSchema", proc_macro2::Span::call_site()), + &rel( + "items", + "HasMany", + quote!(crate::models::nonexistent::Schema), + ), + &[], + None, + ) + }); + assert!(result.is_none()); } } diff --git a/crates/vespera_macro/src/schema_macro/mod.rs b/crates/vespera_macro/src/schema_macro/mod.rs index 8fcdcbb0..51764279 100644 --- a/crates/vespera_macro/src/schema_macro/mod.rs +++ b/crates/vespera_macro/src/schema_macro/mod.rs @@ -6,361 +6,30 @@ mod circular; mod codegen; +mod defaults; pub mod file_cache; mod file_lookup; mod from_model; +mod generate_type; mod inline_types; mod input; +mod same_file_override; mod seaorm; mod transformation; pub mod type_utils; mod validation; pub use file_cache::print_profile_summary; +pub use generate_type::generate_schema_type_code; +pub use input::{SchemaInput, SchemaTypeInput}; -use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use codegen::generate_filtered_schema; -use file_lookup::find_struct_from_path; -use from_model::generate_from_model_with_relations; -use inline_types::{ - generate_inline_relation_type, generate_inline_relation_type_no_relations, - generate_inline_type_definition, -}; -pub use input::{PartialMode, SchemaInput, SchemaTypeInput}; use proc_macro2::TokenStream; -use quote::quote; -use seaorm::{ - RelationFieldInfo, convert_relation_type_to_schema_with_info, convert_type_with_chrono, - extract_sea_orm_default_value, has_sea_orm_primary_key, is_sql_function_default, -}; -use transformation::{ - build_omit_set, build_partial_config, build_pick_set, build_rename_map, determine_rename_all, - extract_doc_attrs, extract_field_serde_attrs, extract_form_data_attrs, - extract_serde_attrs_without_rename_all, filter_out_serde_rename, should_skip_field, - should_wrap_in_option, -}; -use type_utils::{ - capitalize_first, extract_module_path, extract_type_name, is_option_type, is_qualified_path, - is_seaorm_model, is_seaorm_relation_type, snake_to_pascal_case, -}; -use validation::{ - extract_source_field_names, validate_omit_fields, validate_partial_fields, - validate_pick_fields, validate_rename_fields, -}; - -use crate::{ - metadata::StructMetadata, - parser::{extract_default, extract_field_rename, strip_raw_prefix_owned}, -}; +use type_utils::extract_type_name; -#[cfg(test)] -struct __VesperaSameFileLookupFixture { - value: i32, -} - -fn derive_response_base_name(name: &str) -> String { - for suffix in ["Response", "Request", "Schema"] { - if let Some(stripped) = name.strip_suffix(suffix) - && !stripped.is_empty() - { - return stripped.to_string(); - } - } - name.to_string() -} - -fn find_same_file_struct_metadata<'a>( - struct_name: &str, - schema_storage: &'a HashMap, -) -> Option> { - // Cache hit: hand back a borrow so the (potentially large) struct - // definition string is not cloned per lookup. The fallback path - // produces an owned `StructMetadata` from disk, so the unified return - // type is `Cow<'_, StructMetadata>`. - if let Some(metadata) = schema_storage.get(struct_name) { - return Some(Cow::Borrowed(metadata)); - } - - let file_path = proc_macro2::Span::call_site().local_file(); - #[cfg(test)] - let file_path = file_path.or_else(|| { - Some( - std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("src") - .join("schema_macro") - .join("mod.rs"), - ) - }); - let file_path = file_path?; - let definition = file_cache::get_struct_definition(&file_path, struct_name)?; - Some(Cow::Owned(StructMetadata::new( - struct_name.to_string(), - definition, - ))) -} - -fn related_model_type_from_schema_path(schema_path: &TokenStream) -> Option { - let schema_path_str = schema_path.to_string().replace("Schema", "Model"); - syn::parse_str(&schema_path_str).ok() -} - -fn schema_component_name_from_path(schema_path: &TokenStream) -> String { - // Keep the stringified path alive in this scope so the `&str` - // segments borrow from it. The previous implementation collected - // owned `String`s — one allocation per path segment — even though - // each segment is only ever inspected as `&str`. - let path_str = schema_path.to_string(); - let segments: Vec<&str> = path_str.split("::").map(str::trim).collect(); - - if segments.last().is_some_and(|s| *s == "Schema") && segments.len() > 1 { - format!("{}Schema", capitalize_first(segments[segments.len() - 2])) - } else { - segments - .last() - .map_or_else(|| "Schema".to_string(), |s| (*s).to_string()) - } -} - -fn has_derive(struct_item: &syn::ItemStruct, derive_name: &str) -> bool { - struct_item.attrs.iter().any(|attr| { - if !attr.path().is_ident("derive") { - return false; - } - - let mut found = false; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident(derive_name) { - found = true; - } - Ok(()) - }); - found - }) -} - -fn build_named_struct_field_assignments( - struct_item: &syn::ItemStruct, - source_expr: &TokenStream, -) -> syn::Result> { - let syn::Fields::Named(fields_named) = &struct_item.fields else { - return Err(syn::Error::new_spanned( - struct_item, - "same-file relation override DTO must be a named-field struct", - )); - }; - - let assignments = fields_named - .named - .iter() - .filter_map(|field| { - field.ident.as_ref().map(|ident| { - quote! { #ident: #source_expr . #ident.clone() } - }) - }) - .collect(); - - Ok(assignments) -} - -fn build_proxy_fields(struct_item: &syn::ItemStruct) -> syn::Result> { - let syn::Fields::Named(fields_named) = &struct_item.fields else { - return Err(syn::Error::new_spanned( - struct_item, - "same-file relation override DTO must be a named-field struct", - )); - }; - - let fields = fields_named - .named - .iter() - .filter_map(|field| { - field.ident.as_ref().map(|ident| { - let ty = &field.ty; - let attrs: Vec<_> = field - .attrs - .iter() - .filter(|attr| attr.path().is_ident("serde") || attr.path().is_ident("doc")) - .collect(); - quote! { - #(#attrs)* - #ident: #ty - } - }) - }) - .collect(); - - Ok(fields) -} - -fn build_proxy_to_dto_assignments(struct_item: &syn::ItemStruct) -> syn::Result> { - let syn::Fields::Named(fields_named) = &struct_item.fields else { - return Err(syn::Error::new_spanned( - struct_item, - "same-file relation override DTO must be a named-field struct", - )); - }; - - let assignments = fields_named - .named - .iter() - .filter_map(|field| { - field - .ident - .as_ref() - .map(|ident| quote! { #ident: proxy.#ident }) - }) - .collect(); - - Ok(assignments) -} - -fn build_clone_assignments(struct_item: &syn::ItemStruct) -> syn::Result> { - let syn::Fields::Named(fields_named) = &struct_item.fields else { - return Err(syn::Error::new_spanned( - struct_item, - "same-file relation override DTO must be a named-field struct", - )); - }; - - let assignments = fields_named - .named - .iter() - .filter_map(|field| { - field.ident.as_ref().map(|ident| { - quote! { #ident: self.#ident.clone() } - }) - }) - .collect(); - - Ok(assignments) -} - -fn maybe_generate_same_file_relation_override( - new_type_name: &syn::Ident, - field_name: &str, - rel_info: &RelationFieldInfo, - schema_storage: &HashMap, -) -> syn::Result> { - let response_base = derive_response_base_name(&new_type_name.to_string()); - let dto_name = format!("{}In{}", snake_to_pascal_case(field_name), response_base); - let Some(dto_meta) = find_same_file_struct_metadata(&dto_name, schema_storage) else { - return Ok(None); - }; - - let dto_struct: syn::ItemStruct = file_cache::parse_struct_cached(&dto_meta.definition) - .map_err(|e| syn::Error::new(proc_macro2::Span::call_site(), e.to_string()))?; - let dto_ident = syn::Ident::new(&dto_name, proc_macro2::Span::call_site()); - let wrapper_ident = syn::Ident::new( - &format!( - "__Vespera{}{}Relation", - new_type_name, - snake_to_pascal_case(field_name) - ), - proc_macro2::Span::call_site(), - ); - let proxy_ident = syn::Ident::new( - &format!( - "__Vespera{}{}Proxy", - new_type_name, - snake_to_pascal_case(field_name) - ), - proc_macro2::Span::call_site(), - ); - let schema_ref_name = schema_component_name_from_path(&rel_info.schema_path); - - let dto_serde_attrs: Vec<_> = dto_struct - .attrs - .iter() - .filter(|attr| attr.path().is_ident("serde")) - .collect(); - let dto_doc_attrs: Vec<_> = dto_struct - .attrs - .iter() - .filter(|attr| attr.path().is_ident("doc")) - .collect(); - - let proxy_fields = build_proxy_fields(&dto_struct)?; - let proxy_to_dto = build_proxy_to_dto_assignments(&dto_struct)?; - let clone_assignments = build_clone_assignments(&dto_struct)?; - let Some(model_ty) = related_model_type_from_schema_path(&rel_info.schema_path) else { - return Ok(None); - }; - let source_expr = quote! { source }; - let from_model_assignments = build_named_struct_field_assignments(&dto_struct, &source_expr)?; - - // Coalesced helpers: previously three separate `quote!` invocations - // and a `Vec` accumulator were stitched together with - // `#(#helper_tokens)*`. We instead build the conditional Clone / - // Deserialize sub-blocks as their own `TokenStream`s and splice - // them into a single `quote!`, producing the same emitted Rust code - // with one accumulator allocation removed. - let clone_impl = if has_derive(&dto_struct, "Clone") { - quote! {} - } else { - quote! { - impl Clone for #dto_ident { - fn clone(&self) -> Self { - Self { - #(#clone_assignments),* - } - } - } - } - }; - - let deserialize_impl = if has_derive(&dto_struct, "Deserialize") { - quote! {} - } else { - quote! { - #[derive(serde::Deserialize)] - #(#dto_serde_attrs)* - struct #proxy_ident { - #(#proxy_fields),* - } - - impl<'de> serde::Deserialize<'de> for #dto_ident { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let proxy = #proxy_ident::deserialize(deserializer)?; - Ok(Self { - #(#proxy_to_dto),* - }) - } - } - } - }; - - let helpers = quote! { - #clone_impl - #deserialize_impl - - impl From<#model_ty> for #dto_ident { - fn from(source: #model_ty) -> Self { - Self { - #(#from_model_assignments),* - } - } - } - - #(#dto_doc_attrs)* - #[derive(serde::Serialize, serde::Deserialize, Clone, vespera::Schema)] - #[serde(transparent)] - #[schema(ref = #schema_ref_name, nullable)] - struct #wrapper_ident(pub Option<#dto_ident>); - - impl From> for #wrapper_ident { - fn from(source: Option<#model_ty>) -> Self { - Self(source.map(Into::into)) - } - } - }; - - Ok(Some((quote! { #wrapper_ident }, helpers))) -} +use crate::metadata::StructMetadata; /// Generate schema code from a struct with optional field filtering pub fn generate_schema_code( @@ -395,739 +64,423 @@ pub fn generate_schema_code( Ok(schema_tokens) } -/// Generate a new struct type from an existing type with field filtering -/// -/// Returns (`TokenStream`, Option) where the metadata is returned -/// when a custom `name` is provided (for direct registration in `SCHEMA_STORAGE`). -#[allow(clippy::too_many_lines)] -pub fn generate_schema_type_code( - input: &SchemaTypeInput, - schema_storage: &HashMap, -) -> Result<(TokenStream, Option), syn::Error> { - // Extract type name from the source Type - let source_type_name = extract_type_name(&input.source_type)?; - - // Extract the module path for resolving relative paths in relation types - // This may be empty for simple names like `Model` - will be overridden below if found from file - let mut source_module_path = extract_module_path(&input.source_type); - - // Find struct definition - check SCHEMA_STORAGE first (no file I/O), - // fall back to file lookup for types not registered (e.g., SeaORM Model). - let struct_def_owned: StructMetadata; - let schema_name_hint = input.schema_name.as_deref(); - let struct_def = if is_qualified_path(&input.source_type) { - // Qualified path: try storage first (avoids parse_file for Schema-derived types), - // then file lookup for non-Schema types (e.g., SeaORM Model) - if let Some(found) = schema_storage.get(&source_type_name) { - found - } else if let Some((found, module_path)) = - find_struct_from_path(&input.source_type, schema_name_hint) - { - struct_def_owned = found; - // Use the module path from file lookup for qualified paths - // The file lookup derives module path from actual file location, which is more accurate - // for resolving relative paths like `super::user::Entity` - source_module_path = module_path; - &struct_def_owned - } else { - return Err(syn::Error::new_spanned( - &input.source_type, - format!( - "type `{source_type_name}` not found. Either:\n\ - 1. Use #[derive(Schema)] in the same file\n\ - 2. Use full module path like `crate::models::memo::Model` to reference a struct from another file" - ), - )); - } - } else { - // Simple name: try storage first (for same-file structs), then file lookup with schema name hint - if let Some(found) = schema_storage.get(&source_type_name) { - found - } else if let Some((found, module_path)) = - find_struct_from_path(&input.source_type, schema_name_hint) - { - struct_def_owned = found; - // For simple names, we MUST use the inferred module path from the file location - // This is crucial for resolving relative paths like `super::user::Entity` - source_module_path = module_path; - &struct_def_owned - } else { - return Err(syn::Error::new_spanned( - &input.source_type, - format!( - "type `{source_type_name}` not found. Either:\n\ - 1. Use #[derive(Schema)] in the same file\n\ - 2. Use full module path like `crate::models::memo::Model` to reference a struct from another file\n\ - 3. If using `name = \"XxxSchema\"`, ensure the file name matches (e.g., xxx.rs)" - ), - )); - } - }; +#[cfg(test)] +mod tests { + use std::collections::HashMap; - // Parse the struct definition - let parsed_struct: syn::ItemStruct = file_cache::parse_struct_cached(&struct_def.definition) - .map_err(|e| { - syn::Error::new_spanned( - &input.source_type, - format!("failed to parse struct definition for `{source_type_name}`: {e}"), - ) - })?; + use quote::quote; - // Extract all field names from source struct for validation - // Include relation fields since they can be converted to Schema types - let source_field_names = extract_source_field_names(&parsed_struct); - - // Validate all field references exist in source struct - validate_pick_fields( - input.pick.as_ref(), - &source_field_names, - &input.source_type, - &source_type_name, - )?; - validate_omit_fields( - input.omit.as_ref(), - &source_field_names, - &input.source_type, - &source_type_name, - )?; - validate_rename_fields( - input.rename.as_ref(), - &source_field_names, - &input.source_type, - &source_type_name, - )?; - let partial_fields_to_validate = match &input.partial { - Some(PartialMode::Fields(fields)) => Some(fields), - _ => None, - }; - validate_partial_fields( - partial_fields_to_validate, - &source_field_names, - &input.source_type, - &source_type_name, - )?; - - // Build filter sets and rename map - let omit_set = build_omit_set(input.omit.as_ref()); - let pick_set = build_pick_set(input.pick.as_ref()); - let (partial_all, partial_set) = build_partial_config(&input.partial); - let rename_map = build_rename_map(input.rename.as_ref()); - - // Extract serde attributes from source struct, excluding rename_all (we'll handle it separately) - let serde_attrs_without_rename_all = - extract_serde_attrs_without_rename_all(&parsed_struct.attrs); - - // Extract doc comments from source struct to carry over to generated struct - let struct_doc_attrs = extract_doc_attrs(&parsed_struct.attrs); - - // Determine the effective rename_all strategy - let effective_rename_all = - determine_rename_all(input.rename_all.as_ref(), &parsed_struct.attrs); - - // Check if source is a SeaORM Model - let is_source_seaorm_model = is_seaorm_model(&parsed_struct); - - // Generate new struct with filtered fields - let new_type_name = &input.new_type; - let mut field_tokens = Vec::new(); - // Track field mappings for From impl: (new_field_ident, source_field_ident, wrapped_in_option, is_relation) - let mut field_mappings: Vec<(syn::Ident, syn::Ident, bool, bool)> = Vec::new(); - // Track relation field info for from_model generation - let mut relation_fields: Vec = Vec::new(); - // Track inline types that need to be generated for circular relations - let mut inline_type_definitions: Vec = Vec::new(); - // Track default value functions generated from sea_orm(default_value) - let mut default_functions: Vec = Vec::new(); - // Track same-file relation override helpers - let mut relation_override_helpers: Vec = Vec::new(); - - if let syn::Fields::Named(fields_named) = &parsed_struct.fields { - for field in &fields_named.named { - let rust_field_name = field.ident.as_ref().map_or_else( - || "unknown".to_string(), - |i| strip_raw_prefix_owned(i.to_string()), - ); - - // Apply omit/pick filters - if should_skip_field(&rust_field_name, &omit_set, &pick_set) { - continue; - } - - // Apply omit_default: skip fields with sea_orm(default_value) or sea_orm(primary_key) - if input.omit_default - && (extract_sea_orm_default_value(&field.attrs).is_some() - || has_sea_orm_primary_key(&field.attrs)) - { - continue; - } - - // Check if this is a SeaORM relation type - let is_relation = is_seaorm_relation_type(&field.ty); - - // In multipart mode, skip ALL relation fields (multipart forms can't represent nested objects) - if input.multipart && is_relation { - continue; - } - - // Get field components, applying partial wrapping if needed - let original_ty = &field.ty; - let should_wrap_option = should_wrap_in_option( - &rust_field_name, - partial_all, - &partial_set, - is_option_type(original_ty), - is_relation, - ); - - // Determine field type: convert relation types to Schema types - let (field_ty, relation_info): (Box, Option) = - if is_relation { - // Convert HasOne/HasMany/BelongsTo to Schema type - if let Some((converted, mut rel_info)) = - convert_relation_type_to_schema_with_info( - original_ty, - &field.attrs, - &parsed_struct, - &source_module_path, - field.ident.clone().unwrap(), - ) - { - // NEW RULE: HasMany (reverse references) are excluded by default - // They can only be included via explicit `pick` - if rel_info.relation_type == "HasMany" { - // HasMany is only included if explicitly picked - if !pick_set.contains(&rust_field_name) { - continue; - } - // When HasMany IS picked, generate inline type with ALL relations stripped - if let Some(inline_type) = generate_inline_relation_type_no_relations( - new_type_name, - &rel_info, - &source_module_path, - input.schema_name.as_deref(), - ) { - let inline_type_def = generate_inline_type_definition(&inline_type); - inline_type_definitions.push(inline_type_def); - - let inline_type_name = &inline_type.type_name; - let included_fields: Vec = inline_type - .fields - .iter() - .map(|f| f.name.to_string()) - .collect(); - - rel_info.inline_type_info = - Some((inline_type.type_name.clone(), included_fields)); - - let inline_field_ty = quote! { Vec<#inline_type_name> }; - (Box::new(inline_field_ty), Some(rel_info)) - } else { - continue; - } - } else { - // BelongsTo/HasOne: Include by default - if input.add.is_some() - && let Some((override_field_ty, helper_tokens)) = - maybe_generate_same_file_relation_override( - new_type_name, - &rust_field_name, - &rel_info, - schema_storage, - )? - { - relation_override_helpers.push(helper_tokens); - (Box::new(override_field_ty), Some(rel_info)) - } else - // Check for circular references and potentially use inline type - if let Some(inline_type) = generate_inline_relation_type( - new_type_name, - &rel_info, - &source_module_path, - input.schema_name.as_deref(), - ) { - // Generate inline type definition - let inline_type_def = generate_inline_type_definition(&inline_type); - inline_type_definitions.push(inline_type_def); - - // Use inline type instead of direct schema reference - let inline_type_name = &inline_type.type_name; - let circular_fields: Vec = inline_type - .fields - .iter() - .map(|f| f.name.to_string()) - .collect(); - - // Store inline type info - rel_info.inline_type_info = - Some((inline_type.type_name.clone(), circular_fields)); - - // Generate field type using inline type - let inline_field_ty = if rel_info.is_optional { - quote! { Option> } - } else { - quote! { Box<#inline_type_name> } - }; - - (Box::new(inline_field_ty), Some(rel_info)) - } else { - // No circular refs, use original schema path - (Box::new(converted), Some(rel_info)) - } - } - } else { - // Fallback: skip if conversion fails - continue; - } - } else { - // Convert SeaORM datetime types to chrono equivalents - // Also resolves local types to absolute paths - let converted_ty = convert_type_with_chrono(original_ty, &source_module_path); - if should_wrap_option { - (Box::new(quote! { Option<#converted_ty> }), None) - } else { - (Box::new(converted_ty), None) - } - }; - - // Collect relation info — `.extend(...)` keeps the push site - // out of an explicit closure so the coverage tracker - // attributes the call to this source line. - relation_fields.extend(relation_info); - let vis: &syn::Visibility = &field.vis; - let source_field_ident: syn::Ident = field.ident.clone().unwrap(); - - // Extract doc attributes to carry over comments to the generated struct - let doc_attrs = extract_doc_attrs(&field.attrs); - - if input.multipart { - // Multipart mode: emit form_data attrs, suppress serde attrs - let form_data_attrs = extract_form_data_attrs(&field.attrs); - - // Check if field should be renamed (rename still applies to Rust field names) - if let Some(new_name) = rename_map.get(&rust_field_name) { - let new_field_ident = - syn::Ident::new(new_name, field.ident.as_ref().unwrap().span()); - - field_tokens.push(quote! { - #(#doc_attrs)* - #(#form_data_attrs)* - #vis #new_field_ident: #field_ty - }); - - field_mappings.push(( - new_field_ident, - source_field_ident, - should_wrap_option, - is_relation, - )); - } else { - let field_ident = field.ident.clone().unwrap(); - - field_tokens.push(quote! { - #(#doc_attrs)* - #(#form_data_attrs)* - #vis #field_ident: #field_ty - }); - - field_mappings.push(( - field_ident.clone(), - field_ident, - should_wrap_option, - is_relation, - )); - } - } else { - // Normal (serde) mode: emit serde attrs - // Filter field attributes: keep serde and doc attributes, remove sea_orm and others - // This is important when using schema_type! with models from other files - // that may have ORM-specific attributes we don't want in the generated struct - let serde_field_attrs = extract_field_serde_attrs(&field.attrs); - - // Generate serde default + schema(default) from sea_orm(default_value) or primary_key - // Handles literal defaults, SQL function defaults, and implicit auto-increment - let (serde_default_attr, schema_default_attr): ( - proc_macro2::TokenStream, - proc_macro2::TokenStream, - ) = generate_sea_orm_default_attrs( - &field.attrs, - new_type_name, - &rust_field_name, - original_ty, - &field_ty, - should_wrap_option || is_option_type(original_ty), - &mut default_functions, - ); - - // Check if field should be renamed - if let Some(new_name) = rename_map.get(&rust_field_name) { - // Create new identifier for the field - let new_field_ident: syn::Ident = - syn::Ident::new(new_name, field.ident.as_ref().unwrap().span()); - - // Filter out serde(rename) attributes from the serde attrs - let filtered_attrs = filter_out_serde_rename(&serde_field_attrs); - - // Determine the JSON name: use existing serde(rename) if present, otherwise rust field name - let json_name = extract_field_rename(&field.attrs) - .unwrap_or_else(|| rust_field_name.clone()); - - field_tokens.push(quote! { - #(#doc_attrs)* - #(#filtered_attrs)* - #serde_default_attr - #schema_default_attr - #[serde(rename = #json_name)] - #vis #new_field_ident: #field_ty - }); - - // Track mapping: new field name <- source field name - field_mappings.push(( - new_field_ident, - source_field_ident, - should_wrap_option, - is_relation, - )); - } else { - // No rename, keep field with serde and doc attrs - let field_ident = field.ident.clone().unwrap(); - - field_tokens.push(quote! { - #(#doc_attrs)* - #(#serde_field_attrs)* - #serde_default_attr - #schema_default_attr - #vis #field_ident: #field_ty - }); - - // Track mapping: same name - field_mappings.push(( - field_ident.clone(), - field_ident, - should_wrap_option, - is_relation, - )); - } - } - } + use super::defaults::is_parseable_type; + use super::same_file_override::maybe_generate_same_file_relation_override; + use super::seaorm::RelationFieldInfo; + use super::*; + + fn create_test_struct_metadata(name: &str, definition: &str) -> StructMetadata { + StructMetadata::new(name.to_string(), definition.to_string()) } - // Add new fields from `add` parameter - for (field_name, field_ty) in input.add.iter().flatten() { - let field_ident: syn::Ident = syn::Ident::new(field_name, proc_macro2::Span::call_site()); - field_tokens.push(quote! { - pub #field_ident: #field_ty - }); + fn to_storage(items: Vec) -> HashMap { + items.into_iter().map(|s| (s.name.clone(), s)).collect() } - // Build derive list - // In multipart mode, force clone = false (FieldData doesn't implement Clone) - let derive_clone: bool = if input.multipart { - false - } else { - input.derive_clone - }; - let clone_derive: proc_macro2::TokenStream = if derive_clone { - quote! { Clone, } - } else { - quote! {} - }; - - // Conditionally include Schema derive based on ignore_schema flag - // Also generate #[schema(name = "...")] attribute if custom name is provided AND Schema is derived - let schema_derive: proc_macro2::TokenStream; - let schema_name_attr: proc_macro2::TokenStream; - if input.ignore_schema { - schema_derive = quote! {}; - schema_name_attr = quote! {}; - } else if let Some(ref name) = input.schema_name { - schema_derive = quote! { vespera::Schema }; - schema_name_attr = quote! { #[schema(name = #name)] }; - } else { - schema_derive = quote! { vespera::Schema }; - schema_name_attr = quote! {}; + #[test] + fn test_generate_schema_code_simple_struct() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(User); + let input: SchemaInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_code(&input, &storage); + + assert!(result.is_ok()); + let output = result.unwrap().to_string(); + assert!(output.contains("properties")); + assert!(output.contains("Schema")); } - // Check if there are any relation fields - let has_relation_fields = field_mappings.iter().any(|(_, _, _, is_rel)| *is_rel); - - // In multipart mode, skip From and from_model impls entirely - let source_type: &syn::Type = &input.source_type; - let (from_impl, from_model_impl) = if input.multipart { - (quote! {}, quote! {}) - } else { - // Generate From impl only if: - // 1. `add` is not used (can't auto-populate added fields) - // 2. There are no relation fields (relation fields don't exist on source Model) - let from_impl = if input.add.is_none() && !has_relation_fields { - let field_assignments: Vec<_> = field_mappings - .iter() - .map(|(new_ident, source_ident, wrapped, _is_relation)| { - if *wrapped { - quote! { #new_ident: Some(source.#source_ident) } - } else { - quote! { #new_ident: source.#source_ident } - } - }) - .collect(); - - quote! { - impl From<#source_type> for #new_type_name { - fn from(source: #source_type) -> Self { - Self { - #(#field_assignments),* - } - } - } - } - } else { - quote! {} - }; + #[test] + fn test_generate_schema_code_with_omit() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String, pub password: String }", + )]); - // Generate from_model impl for SeaORM Models WITH relations - // - No relations: Use `From` trait (generated above) - // - Has relations: async fn from_model(model: Model, db: &DatabaseConnection) -> Result - let from_model_impl = - if is_source_seaorm_model && input.add.is_none() && has_relation_fields { - generate_from_model_with_relations( - new_type_name, - source_type, - &field_mappings, - &relation_fields, - &source_module_path, - schema_storage, - ) - } else { - quote! {} - }; - - (from_impl, from_model_impl) - }; - - // Generate the new struct (with inline types for circular relations first) - let generated_tokens: proc_macro2::TokenStream = if input.multipart { - // Multipart mode: derive Multipart instead of serde - // Emit #[serde(rename_all = ...)] so Multipart applies the rename at runtime - // AND Schema derive reads it via extract_rename_all() fallback for OpenAPI field naming - quote! { - #(#inline_type_definitions)* - - #(#struct_doc_attrs)* - #[derive(vespera::Multipart, #clone_derive #schema_derive)] - #schema_name_attr - #[serde(rename_all = #effective_rename_all)] - pub struct #new_type_name { - #(#field_tokens),* - } - } - } else { - // Normal serde mode - quote! { - // Inline types for circular relation references - #(#inline_type_definitions)* - - // Same-file relation override helpers - #(#relation_override_helpers)* - - // Default value functions for sea_orm(default_value) fields - #(#default_functions)* - - #(#struct_doc_attrs)* - #[derive(serde::Serialize, serde::Deserialize, #clone_derive #schema_derive)] - #schema_name_attr - #[serde(rename_all = #effective_rename_all)] - #(#serde_attrs_without_rename_all)* - pub struct #new_type_name { - #(#field_tokens),* - } - - #from_impl - #from_model_impl - } - }; - - // If custom name is provided, create metadata for direct registration - // This ensures the schema appears in OpenAPI even when `ignore` is set - let metadata = input.schema_name.as_ref().map(|custom_name| { - // Build struct definition string for metadata (without derives/attrs for parsing) - let struct_def = quote! { - #[serde(rename_all = #effective_rename_all)] - #(#serde_attrs_without_rename_all)* - pub struct #new_type_name { - #(#field_tokens),* - } - }; - StructMetadata::new(custom_name.clone(), struct_def.to_string()) - }); + let tokens = quote!(User, omit = ["password"]); + let input: SchemaInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_code(&input, &storage); - Ok((generated_tokens, metadata)) -} + assert!(result.is_ok()); + let output = result.unwrap().to_string(); + assert!(output.contains("properties")); + } + + #[test] + fn test_generate_schema_code_with_pick() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String, pub email: String }", + )]); -/// Generate `#[serde(default = "...")]` and `#[schema(default = "...")]` attributes -/// from `#[sea_orm(default_value = ...)]` or `#[sea_orm(primary_key)]` on source fields. -/// -/// Returns `(serde_default_attr, schema_default_attr)` as `TokenStream`s. -/// - `serde_default_attr`: `#[serde(default = "default_structname_field")]` for deserialization -/// - `schema_default_attr`: `#[schema(default = "value")]` for OpenAPI default value -/// -/// Also generates a companion default function and appends it to `default_functions`. -/// -/// Handles three categories of defaults: -/// 1. **Literal defaults** (`default_value = "42"`, `"draft"`, `0.7`): -/// Generates parse-based default function + schema default. -/// 2. **SQL function defaults** (`default_value = "NOW()"`, `"gen_random_uuid()"`): -/// Generates type-specific default function + schema default with type's zero value. -/// 3. **Primary key** (implicit auto-increment): -/// Treated as having an implicit default — generates type-specific default. -/// -/// Skips serde default generation when: -/// - The field is wrapped in `Option` (partial mode or already optional) -/// - The field already has `#[serde(default)]` -/// - For literal defaults: the field type doesn't implement `FromStr` -fn generate_sea_orm_default_attrs( - original_attrs: &[syn::Attribute], - struct_name: &syn::Ident, - field_name: &str, - original_ty: &syn::Type, - field_ty: &dyn quote::ToTokens, - is_optional_or_partial: bool, - default_functions: &mut Vec, -) -> (TokenStream, TokenStream) { - // Don't generate defaults for optional/partial fields - if is_optional_or_partial { - return (quote! {}, quote! {}); + let tokens = quote!(User, pick = ["id", "name"]); + let input: SchemaInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_code(&input, &storage); + + assert!(result.is_ok()); + let output = result.unwrap().to_string(); + assert!(output.contains("properties")); } - // Check for sea_orm(default_value) and sea_orm(primary_key) - let default_value = extract_sea_orm_default_value(original_attrs); - let has_pk = has_sea_orm_primary_key(original_attrs); + #[test] + fn test_generate_schema_code_type_not_found() { + let storage: HashMap = HashMap::new(); + + let tokens = quote!(NonExistent); + let input: SchemaInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_code(&input, &storage); - // No default source found - if default_value.is_none() && !has_pk { - return (quote! {}, quote! {}); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("not found")); } - let has_existing_serde_default = extract_default(original_attrs).is_some(); + #[test] + fn test_generate_schema_code_malformed_definition() { + let storage = to_storage(vec![create_test_struct_metadata( + "BadStruct", + "this is not valid rust code {{{", + )]); + + let tokens = quote!(BadStruct); + let input: SchemaInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_code(&input, &storage); - match &default_value { - // Literal default (e.g., "42", "draft", "0.7") - Some(value) if !is_sql_function_default(value) => { - let schema_default_attr = quote! { #[schema(default = #value)] }; + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("failed to parse")); + } - if has_existing_serde_default { - return (quote! {}, schema_default_attr); - } + #[test] + fn test_generate_schema_type_code_pick_nonexistent_field() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(NewUser from User, pick = ["nonexistent"]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("does not exist")); + assert!(err.contains("nonexistent")); + } - if !is_parseable_type(original_ty) { - return (quote! {}, schema_default_attr); - } + #[test] + fn test_generate_schema_type_code_omit_nonexistent_field() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(NewUser from User, omit = ["nonexistent"]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("does not exist")); + assert!(err.contains("nonexistent")); + } - let fn_name = format!("default_{struct_name}_{field_name}"); - let fn_ident = syn::Ident::new(&fn_name, proc_macro2::Span::call_site()); + #[test] + fn test_generate_schema_type_code_rename_nonexistent_field() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(NewUser from User, rename = [("nonexistent", "new_name")]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("does not exist")); + assert!(err.contains("nonexistent")); + } - default_functions.push(quote! { - #[allow(non_snake_case)] - fn #fn_ident() -> #field_ty { - #value.parse().unwrap() - } - }); + #[test] + fn test_generate_schema_type_code_type_not_found() { + let storage: HashMap = HashMap::new(); - let serde_default_attr = quote! { #[serde(default = #fn_name)] }; - (serde_default_attr, schema_default_attr) - } - // SQL function default (NOW(), gen_random_uuid(), etc.) or primary_key auto-increment - _ => { - let Some((default_expr, schema_default_str)) = - sql_function_default_for_type(original_ty) - else { - return (quote! {}, quote! {}); - }; - - let schema_default_attr = quote! { #[schema(default = #schema_default_str)] }; - - if has_existing_serde_default { - return (quote! {}, schema_default_attr); - } - - let fn_name = format!("default_{struct_name}_{field_name}"); - let fn_ident = syn::Ident::new(&fn_name, proc_macro2::Span::call_site()); - - default_functions.push(quote! { - #[allow(non_snake_case)] - fn #fn_ident() -> #field_ty { - #default_expr - } - }); - - let serde_default_attr = quote! { #[serde(default = #fn_name)] }; - (serde_default_attr, schema_default_attr) - } + let tokens = quote!(NewUser from NonExistent); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("not found")); } -} -/// Return a type-appropriate (Rust default expression, OpenAPI default string) pair -/// for fields with SQL function defaults or implicit auto-increment. -/// -/// The Rust expression is used in the generated `#[serde(default = "fn")]` function body. -/// The OpenAPI string is used in `#[schema(default = "value")]`. -fn sql_function_default_for_type(original_ty: &syn::Type) -> Option<(TokenStream, String)> { - let syn::Type::Path(type_path) = original_ty else { - return None; - }; - let segment = type_path.path.segments.last()?; - let type_name = segment.ident.to_string(); - - match type_name.as_str() { - "DateTimeWithTimeZone" | "DateTimeUtc" | "DateTime" => { - let expr = quote! { - vespera::chrono::DateTime::::UNIX_EPOCH.fixed_offset() - }; - Some((expr, "1970-01-01T00:00:00+00:00".to_string())) - } - "NaiveDateTime" => { - let expr = quote! { - vespera::chrono::NaiveDateTime::UNIX_EPOCH - }; - Some((expr, "1970-01-01T00:00:00".to_string())) - } - "NaiveDate" => { - let expr = quote! { - vespera::chrono::NaiveDate::default() - }; - Some((expr, "1970-01-01".to_string())) - } - "NaiveTime" | "Time" => { - let expr = quote! { - vespera::chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap() - }; - Some((expr, "00:00:00".to_string())) - } - "Uuid" => Some(( - quote! { Default::default() }, - "00000000-0000-0000-0000-000000000000".to_string(), - )), - "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128" - | "usize" | "f32" | "f64" | "Decimal" => { - Some((quote! { Default::default() }, "0".to_string())) + #[test] + fn test_generate_schema_type_code_success() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(CreateUser from User, pick = ["name"]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("CreateUser")); + assert!(output.contains("name")); + } + + #[test] + fn test_generate_schema_type_code_with_omit() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String, pub password: String }", + )]); + + let tokens = quote!(SafeUser from User, omit = ["password"]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("SafeUser")); + assert!(!output.contains("password")); + } + + #[test] + fn test_generate_schema_type_code_with_add() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(UserWithExtra from User, add = [("extra": String)]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("UserWithExtra")); + assert!(output.contains("extra")); + } + + #[test] + fn test_generate_schema_type_code_relation_fields_can_be_omitted_and_readded_with_custom_types() + { + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "article")] + pub struct Model { + pub id: i64, + pub title: String, + pub user: HasOne, + pub category: HasOne, + pub article_review_users: HasMany + }"#, + )]); + + let tokens = quote!( + ArticleResponse from Model, + omit = ["user", "category", "article_review_users"], + add = [ + ("user": Option), + ("category": Option), + ("article_review_users": Vec) + ] + ); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("pub user : Option < UserInArticle >")); + assert!(output.contains("pub category : Option < CategoryInArticle >")); + assert!(output.contains("pub article_review_users : Vec < ArticleReviewUserInArticle >")); + assert!(!output.contains("Box < Schema >")); + assert!(!output.contains("impl From")); + } + + #[test] + fn test_generate_schema_type_code_same_file_relation_adapters_for_add_mode() { + let storage = to_storage(vec![ + create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "article")] + pub struct Model { + pub id: i64, + pub title: String, + pub user: HasOne, + pub category: HasOne, + pub article_review_users: HasMany + }"#, + ), + create_test_struct_metadata( + "UserInArticle", + "struct UserInArticle { id: i32, name: String }", + ), + create_test_struct_metadata( + "CategoryInArticle", + "struct CategoryInArticle { id: i64, name: String }", + ), + ]); + + let tokens = quote!( + ArticleResponse from Model, + add = [("article_review_users": Vec)] + ); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("pub user : __VesperaArticleResponseUserRelation")); + assert!(output.contains("pub category : __VesperaArticleResponseCategoryRelation")); + assert!(output.contains("impl From < Option <")); + assert!(output.contains("for __VesperaArticleResponseUserRelation")); + assert!(output.contains("for __VesperaArticleResponseCategoryRelation")); + assert!(output.contains("impl Clone for UserInArticle")); + assert!(output.contains("impl Clone for CategoryInArticle")); + } + + #[test] + fn test_maybe_generate_same_file_relation_override_skips_redundant_clone_and_deserialize_impls() + { + // Same-file relation override DTOs that ALREADY carry `Clone` and + // `Deserialize` derives must NOT have the macro re-emit those + // impls — otherwise the generated code would conflict with the + // user-provided derive. Hits the "DTO already has derive" empty- + // quote branches inside `maybe_generate_same_file_relation_override`. + let rel_info = RelationFieldInfo { + field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), + relation_type: "HasOne".to_string(), + schema_path: quote!(crate::models::user::Schema), + is_optional: true, + inline_type_info: None, + relation_enum: None, + fk_column: None, + via_rel: None, + }; + // Bare `Clone` and `Deserialize` idents — has_derive matches the + // single-segment path, hitting the empty-quote branches at lines + // 208 (clone_impl) and 222 (deserialize_impl). + let storage = to_storage(vec![create_test_struct_metadata( + "UserInArticle", + r"#[derive(Clone, Deserialize)] + struct UserInArticle { id: i32, name: String }", + )]); + let new_type_name = syn::Ident::new("ArticleResponse", proc_macro2::Span::call_site()); + + let (override_field_ty, helper_tokens) = + maybe_generate_same_file_relation_override(&new_type_name, "user", &rel_info, &storage) + .expect("override generation should succeed") + .expect("DTO is present in storage → override should be generated"); + + let output = helper_tokens.to_string(); + let field_ty = override_field_ty.to_string(); + assert!( + field_ty.contains("__VesperaArticleResponseUserRelation"), + "expected override field type to reference relation adapter, got: {field_ty}" + ); + // No `impl Clone for UserInArticle` — DTO already derives Clone. + assert!( + !output.contains("impl Clone for UserInArticle"), + "macro should skip Clone impl when DTO already derives Clone, got: {output}" + ); + // No proxy `Deserialize` derive struct — DTO already derives Deserialize. + assert!( + !output.contains("__VesperaArticleResponseUserProxy"), + "macro should skip Deserialize proxy when DTO already derives Deserialize, got: {output}" + ); + // Relation wrapper struct still emitted regardless of derives. + assert!( + output.contains("__VesperaArticleResponseUserRelation"), + "relation wrapper missing: {output}" + ); + } + + #[test] + fn test_generate_schema_type_code_generates_from_impl() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(UserResponse from User, pick = ["id", "name"]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("impl From")); + assert!(output.contains("for UserResponse")); + } + + #[test] + fn test_generate_schema_type_code_no_from_impl_with_add() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(UserWithExtra from User, add = [("extra": String)]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!( + output.contains("UserWithExtra"), + "expected struct UserWithExtra in output: {output}" + ); + assert!( + !output.contains("impl From"), + "expected no From impl when `add` is used: {output}" + ); + } + + // ======================== + // is_parseable_type tests + // ======================== + + #[test] + fn test_is_parseable_type_primitives() { + for ty_str in &[ + "i8", "i16", "i32", "i64", "i128", "isize", "u8", "u16", "u32", "u64", "u128", "usize", + "f32", "f64", "bool", "String", "Decimal", + ] { + let ty: syn::Type = syn::parse_str(ty_str).unwrap(); + assert!(is_parseable_type(&ty), "{ty_str} should be parseable"); } - "bool" => Some((quote! { Default::default() }, "false".to_string())), - "String" => Some((quote! { Default::default() }, String::new())), - _ => None, } -} -/// Check if a type is known to implement `FromStr` and can use `.parse().unwrap()`. -/// -/// Returns true for primitive types, String, and Decimal. -/// Returns false for enums and unknown custom types. -fn is_parseable_type(ty: &syn::Type) -> bool { - let syn::Type::Path(type_path) = ty else { - return false; - }; - let Some(segment) = type_path.path.segments.last() else { - return false; - }; - type_utils::PRIMITIVE_TYPE_NAMES.contains(&segment.ident.to_string().as_str()) -} + #[test] + fn test_is_parseable_type_non_parseable() { + let ty: syn::Type = syn::parse_str("MyEnum").unwrap(); + assert!(!is_parseable_type(&ty)); + } -#[cfg(test)] -mod tests; + #[test] + fn test_is_parseable_type_non_path() { + let ty: syn::Type = syn::parse_str("&str").unwrap(); + assert!(!is_parseable_type(&ty)); + } +} diff --git a/crates/vespera_macro/src/schema_macro/same_file_override.rs b/crates/vespera_macro/src/schema_macro/same_file_override.rs new file mode 100644 index 00000000..39bc57ad --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/same_file_override.rs @@ -0,0 +1,491 @@ +//! Same-file relation override: route-local DTOs named +//! `{RelationPascal}In{ResponseBase}` replace single-value relation +//! schemas without changing handler construction code (see README +//! "Same-File Relation Adapters"). + +use std::borrow::Cow; +use std::collections::HashMap; + +use proc_macro2::TokenStream; +use quote::quote; + +use super::file_cache; +use super::seaorm::RelationFieldInfo; +use super::type_utils::{capitalize_first, snake_to_pascal_case}; +use crate::metadata::StructMetadata; +#[cfg(test)] +pub(super) struct __VesperaSameFileLookupFixture { + value: i32, +} + +pub(super) fn derive_response_base_name(name: &str) -> String { + for suffix in ["Response", "Request", "Schema"] { + if let Some(stripped) = name.strip_suffix(suffix) + && !stripped.is_empty() + { + return stripped.to_string(); + } + } + name.to_string() +} + +pub(super) fn find_same_file_struct_metadata<'a>( + struct_name: &str, + schema_storage: &'a HashMap, +) -> Option> { + // Cache hit: hand back a borrow so the (potentially large) struct + // definition string is not cloned per lookup. The fallback path + // produces an owned `StructMetadata` from disk, so the unified return + // type is `Cow<'_, StructMetadata>`. + if let Some(metadata) = schema_storage.get(struct_name) { + return Some(Cow::Borrowed(metadata)); + } + + let file_path = proc_macro2::Span::call_site().local_file(); + #[cfg(test)] + let file_path = file_path.or_else(|| { + Some( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src") + .join("schema_macro") + .join("same_file_override.rs"), + ) + }); + let file_path = file_path?; + let definition = file_cache::get_struct_definition(&file_path, struct_name)?; + Some(Cow::Owned(StructMetadata::new( + struct_name.to_string(), + definition, + ))) +} + +pub(super) fn related_model_type_from_schema_path(schema_path: &TokenStream) -> Option { + let schema_path_str = schema_path.to_string().replace("Schema", "Model"); + syn::parse_str(&schema_path_str).ok() +} + +pub(super) fn schema_component_name_from_path(schema_path: &TokenStream) -> String { + // Keep the stringified path alive in this scope so the `&str` + // segments borrow from it. The previous implementation collected + // owned `String`s — one allocation per path segment — even though + // each segment is only ever inspected as `&str`. + let path_str = schema_path.to_string(); + let segments: Vec<&str> = path_str.split("::").map(str::trim).collect(); + + if segments.last().is_some_and(|s| *s == "Schema") && segments.len() > 1 { + format!("{}Schema", capitalize_first(segments[segments.len() - 2])) + } else { + segments + .last() + .map_or_else(|| "Schema".to_string(), |s| (*s).to_string()) + } +} + +pub(super) fn has_derive(struct_item: &syn::ItemStruct, derive_name: &str) -> bool { + struct_item.attrs.iter().any(|attr| { + if !attr.path().is_ident("derive") { + return false; + } + + let mut found = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident(derive_name) { + found = true; + } + Ok(()) + }); + found + }) +} + +pub(super) fn build_named_struct_field_assignments( + struct_item: &syn::ItemStruct, + source_expr: &TokenStream, +) -> syn::Result> { + let syn::Fields::Named(fields_named) = &struct_item.fields else { + return Err(syn::Error::new_spanned( + struct_item, + "same-file relation override DTO must be a named-field struct", + )); + }; + + let assignments = fields_named + .named + .iter() + .filter_map(|field| { + field.ident.as_ref().map(|ident| { + quote! { #ident: #source_expr . #ident.clone() } + }) + }) + .collect(); + + Ok(assignments) +} + +pub(super) fn build_proxy_fields(struct_item: &syn::ItemStruct) -> syn::Result> { + let syn::Fields::Named(fields_named) = &struct_item.fields else { + return Err(syn::Error::new_spanned( + struct_item, + "same-file relation override DTO must be a named-field struct", + )); + }; + + let fields = fields_named + .named + .iter() + .filter_map(|field| { + field.ident.as_ref().map(|ident| { + let ty = &field.ty; + let attrs: Vec<_> = field + .attrs + .iter() + .filter(|attr| attr.path().is_ident("serde") || attr.path().is_ident("doc")) + .collect(); + quote! { + #(#attrs)* + #ident: #ty + } + }) + }) + .collect(); + + Ok(fields) +} + +pub(super) fn build_proxy_to_dto_assignments( + struct_item: &syn::ItemStruct, +) -> syn::Result> { + let syn::Fields::Named(fields_named) = &struct_item.fields else { + return Err(syn::Error::new_spanned( + struct_item, + "same-file relation override DTO must be a named-field struct", + )); + }; + + let assignments = fields_named + .named + .iter() + .filter_map(|field| { + field + .ident + .as_ref() + .map(|ident| quote! { #ident: proxy.#ident }) + }) + .collect(); + + Ok(assignments) +} + +pub(super) fn build_clone_assignments( + struct_item: &syn::ItemStruct, +) -> syn::Result> { + let syn::Fields::Named(fields_named) = &struct_item.fields else { + return Err(syn::Error::new_spanned( + struct_item, + "same-file relation override DTO must be a named-field struct", + )); + }; + + let assignments = fields_named + .named + .iter() + .filter_map(|field| { + field.ident.as_ref().map(|ident| { + quote! { #ident: self.#ident.clone() } + }) + }) + .collect(); + + Ok(assignments) +} + +pub(super) fn maybe_generate_same_file_relation_override( + new_type_name: &syn::Ident, + field_name: &str, + rel_info: &RelationFieldInfo, + schema_storage: &HashMap, +) -> syn::Result> { + let response_base = derive_response_base_name(&new_type_name.to_string()); + let dto_name = format!("{}In{}", snake_to_pascal_case(field_name), response_base); + let Some(dto_meta) = find_same_file_struct_metadata(&dto_name, schema_storage) else { + return Ok(None); + }; + + let dto_struct: syn::ItemStruct = file_cache::parse_struct_cached(&dto_meta.definition) + .map_err(|e| syn::Error::new(proc_macro2::Span::call_site(), e.to_string()))?; + let dto_ident = syn::Ident::new(&dto_name, proc_macro2::Span::call_site()); + let wrapper_ident = syn::Ident::new( + &format!( + "__Vespera{}{}Relation", + new_type_name, + snake_to_pascal_case(field_name) + ), + proc_macro2::Span::call_site(), + ); + let proxy_ident = syn::Ident::new( + &format!( + "__Vespera{}{}Proxy", + new_type_name, + snake_to_pascal_case(field_name) + ), + proc_macro2::Span::call_site(), + ); + let schema_ref_name = schema_component_name_from_path(&rel_info.schema_path); + + let dto_serde_attrs: Vec<_> = dto_struct + .attrs + .iter() + .filter(|attr| attr.path().is_ident("serde")) + .collect(); + let dto_doc_attrs: Vec<_> = dto_struct + .attrs + .iter() + .filter(|attr| attr.path().is_ident("doc")) + .collect(); + + let proxy_fields = build_proxy_fields(&dto_struct)?; + let proxy_to_dto = build_proxy_to_dto_assignments(&dto_struct)?; + let clone_assignments = build_clone_assignments(&dto_struct)?; + let Some(model_ty) = related_model_type_from_schema_path(&rel_info.schema_path) else { + return Ok(None); + }; + let source_expr = quote! { source }; + let from_model_assignments = build_named_struct_field_assignments(&dto_struct, &source_expr)?; + + // Coalesced helpers: previously three separate `quote!` invocations + // and a `Vec` accumulator were stitched together with + // `#(#helper_tokens)*`. We instead build the conditional Clone / + // Deserialize sub-blocks as their own `TokenStream`s and splice + // them into a single `quote!`, producing the same emitted Rust code + // with one accumulator allocation removed. + let clone_impl = if has_derive(&dto_struct, "Clone") { + quote! {} + } else { + quote! { + impl Clone for #dto_ident { + fn clone(&self) -> Self { + Self { + #(#clone_assignments),* + } + } + } + } + }; + + let deserialize_impl = if has_derive(&dto_struct, "Deserialize") { + quote! {} + } else { + quote! { + #[derive(serde::Deserialize)] + #(#dto_serde_attrs)* + struct #proxy_ident { + #(#proxy_fields),* + } + + impl<'de> serde::Deserialize<'de> for #dto_ident { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let proxy = #proxy_ident::deserialize(deserializer)?; + Ok(Self { + #(#proxy_to_dto),* + }) + } + } + } + }; + + let helpers = quote! { + #clone_impl + #deserialize_impl + + impl From<#model_ty> for #dto_ident { + fn from(source: #model_ty) -> Self { + Self { + #(#from_model_assignments),* + } + } + } + + #(#dto_doc_attrs)* + #[derive(serde::Serialize, serde::Deserialize, Clone, vespera::Schema)] + #[serde(transparent)] + #[schema(ref = #schema_ref_name, nullable)] + struct #wrapper_ident(pub Option<#dto_ident>); + + impl From> for #wrapper_ident { + fn from(source: Option<#model_ty>) -> Self { + Self(source.map(Into::into)) + } + } + }; + + Ok(Some((quote! { #wrapper_ident }, helpers))) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use quote::quote; + + use super::*; + use crate::metadata::StructMetadata; + use crate::schema_macro::seaorm::RelationFieldInfo; + use crate::schema_macro::{SchemaTypeInput, generate_schema_type_code}; + + fn create_test_struct_metadata(name: &str, definition: &str) -> StructMetadata { + StructMetadata::new(name.to_string(), definition.to_string()) + } + + fn to_storage(items: Vec) -> HashMap { + items.into_iter().map(|s| (s.name.clone(), s)).collect() + } + + #[test] + fn test_derive_response_base_name_handles_known_suffixes_and_fallback() { + assert_eq!(derive_response_base_name("UserResponse"), "User"); + assert_eq!(derive_response_base_name("UserRequest"), "User"); + assert_eq!(derive_response_base_name("UserSchema"), "User"); + assert_eq!(derive_response_base_name("User"), "User"); + } + + #[test] + fn test_find_same_file_struct_metadata_reads_test_fixture_from_current_module() { + let storage: HashMap = HashMap::new(); + let metadata = find_same_file_struct_metadata("__VesperaSameFileLookupFixture", &storage) + .expect("fixture should be found in schema_macro/same_file_override.rs"); + + assert_eq!(metadata.name, "__VesperaSameFileLookupFixture"); + assert!( + metadata + .definition + .contains("__VesperaSameFileLookupFixture") + ); + assert!(metadata.definition.contains("value")); + } + + #[test] + fn test_has_derive_ignores_non_derive_attrs_and_detects_requested_derive() { + let struct_item: syn::ItemStruct = syn::parse_str( + r#" + #[serde(rename_all = "camelCase")] + #[derive(Clone, Debug)] + struct Sample { + value: i32, + } + "#, + ) + .unwrap(); + + assert!(has_derive(&struct_item, "Clone")); + assert!(!has_derive(&struct_item, "Deserialize")); + } + + #[test] + fn test_build_named_struct_field_assignments_rejects_tuple_structs() { + let struct_item: syn::ItemStruct = syn::parse_str("struct TupleDto(String);").unwrap(); + let source_expr = quote!(source); + let error = build_named_struct_field_assignments(&struct_item, &source_expr).unwrap_err(); + assert!(error.to_string().contains("named-field struct")); + } + + #[test] + fn test_build_proxy_fields_rejects_tuple_structs() { + let struct_item: syn::ItemStruct = syn::parse_str("struct TupleDto(String);").unwrap(); + let error = build_proxy_fields(&struct_item).unwrap_err(); + assert!(error.to_string().contains("named-field struct")); + } + + #[test] + fn test_build_proxy_to_dto_assignments_rejects_tuple_structs() { + let struct_item: syn::ItemStruct = syn::parse_str("struct TupleDto(String);").unwrap(); + let error = build_proxy_to_dto_assignments(&struct_item).unwrap_err(); + assert!(error.to_string().contains("named-field struct")); + } + + #[test] + fn test_build_clone_assignments_rejects_tuple_structs() { + let struct_item: syn::ItemStruct = syn::parse_str("struct TupleDto(String);").unwrap(); + let error = build_clone_assignments(&struct_item).unwrap_err(); + assert!(error.to_string().contains("named-field struct")); + } + + #[test] + fn test_maybe_generate_same_file_relation_override_returns_none_when_dto_is_missing() { + let rel_info = RelationFieldInfo { + field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), + relation_type: "HasOne".to_string(), + schema_path: quote!(crate::models::user::Schema), + is_optional: true, + inline_type_info: None, + relation_enum: None, + fk_column: None, + via_rel: None, + }; + + let storage: HashMap = HashMap::new(); + let new_type_name = syn::Ident::new("ArticleResponse", proc_macro2::Span::call_site()); + + let result = + maybe_generate_same_file_relation_override(&new_type_name, "user", &rel_info, &storage) + .expect("missing dto should not error"); + assert!(result.is_none()); + } + + #[test] + fn test_maybe_generate_same_file_relation_override_returns_none_for_invalid_model_type() { + let rel_info = RelationFieldInfo { + field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), + relation_type: "HasOne".to_string(), + schema_path: quote!(?), + is_optional: true, + inline_type_info: None, + relation_enum: None, + fk_column: None, + via_rel: None, + }; + + let storage = to_storage(vec![create_test_struct_metadata( + "UserInArticle", + "struct UserInArticle { id: i32 }", + )]); + let new_type_name = syn::Ident::new("ArticleResponse", proc_macro2::Span::call_site()); + + let result = + maybe_generate_same_file_relation_override(&new_type_name, "user", &rel_info, &storage) + .expect("invalid model type should not error"); + assert!(result.is_none()); + } + + #[test] + fn test_generate_schema_type_code_normal_mode_relation_rename_and_custom_name() { + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "articles")] + pub struct Model { + pub id: i32, + pub name: String, + pub owner: HasOne + }"#, + )]); + + let tokens = quote!( + ArticleResponse from Model, + name = "CustomArticleSchema", + rename = [("name", "display_name")] + ); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("display_name")); + assert!(output.contains("owner")); + assert!(output.contains("Clone")); + assert!(output.contains("CustomArticleSchema")); + assert_eq!(metadata.unwrap().name, "CustomArticleSchema"); + } +} diff --git a/crates/vespera_macro/src/schema_macro/seaorm.rs b/crates/vespera_macro/src/schema_macro/seaorm.rs index 97ba9f50..fa52df46 100644 --- a/crates/vespera_macro/src/schema_macro/seaorm.rs +++ b/crates/vespera_macro/src/schema_macro/seaorm.rs @@ -1,1259 +1,347 @@ -//! `SeaORM` and Chrono type conversions -//! -//! Handles conversion of `SeaORM` relation types and datetime types to their -//! schema equivalents. - -use proc_macro2::TokenStream; -use quote::quote; -use syn::Type; +//! `SeaORM` and Chrono type conversions. + +mod attrs; +mod conversion; +mod relations; + +#[allow(unused_imports)] +pub use attrs::{ + extract_belongs_to_from_field, extract_relation_enum, extract_sea_orm_default_value, + extract_via_rel, has_sea_orm_primary_key, is_sql_function_default, +}; +#[allow(unused_imports)] +pub use conversion::{convert_seaorm_type_to_chrono, convert_type_with_chrono}; +#[allow(unused_imports)] +pub use relations::{ + RelationFieldInfo, convert_relation_type_to_schema_with_info, is_field_optional_in_struct, +}; + +// Circular-relation integration tests live here because relation +// conversion (`convert_relation_type_to_schema_with_info`) is the +// seaorm-owned behavior they exercise end-to-end. +#[cfg(test)] +mod circular_relation_tests { + use std::collections::HashMap; -use super::type_utils::{is_option_type, resolve_type_to_absolute_path}; + use quote::quote; + use serial_test::serial; -/// Relation field info for generating `from_model` code -#[derive(Clone)] -pub struct RelationFieldInfo { - /// Field name in the generated struct - pub field_name: syn::Ident, - /// Relation type: "`HasOne`", "`HasMany`", or "`BelongsTo`" - pub relation_type: String, - /// Target Schema path (e.g., `crate::models::user::Schema`) - pub schema_path: TokenStream, - /// Whether the relation is optional - pub is_optional: bool, - /// If Some, this relation has circular refs and uses an inline type - /// Contains: (`inline_type_name`, `circular_fields_to_exclude`) - pub inline_type_info: Option<(syn::Ident, Vec)>, - /// The `relation_enum` attribute value (e.g., "`TargetUser`", "`CreatedByUser`") - /// When present, indicates multiple relations to the same Entity type exist - pub relation_enum: Option, - /// The FK column name from `from` attribute (e.g., "`user_id`", "`target_user_id`") - pub fk_column: Option, - /// The `via_rel` attribute value for `HasMany` relations (e.g., "`TargetUser`") - /// This specifies which Relation variant on the TARGET entity to use - pub via_rel: Option, -} + use crate::metadata::StructMetadata; + use crate::schema_macro::{SchemaTypeInput, generate_schema_type_code}; -/// Convert `SeaORM` datetime types to chrono equivalents. -/// -/// This allows generated schemas to use standard chrono types instead of -/// requiring `use sea_orm::entity::prelude::DateTimeWithTimeZone`. -/// -/// Conversions: -/// - `DateTimeWithTimeZone` -> `chrono::DateTime` -/// - `DateTimeUtc` -> `chrono::DateTime` -/// - `DateTimeLocal` -> `chrono::DateTime` -/// - `DateTime` (`SeaORM`) -> `chrono::NaiveDateTime` -/// - `Date` (`SeaORM`) -> `chrono::NaiveDate` -/// - `Time` (`SeaORM`) -> `chrono::NaiveTime` -/// -/// Returns the original type as `TokenStream` if not a `SeaORM` datetime type. -pub fn convert_seaorm_type_to_chrono(ty: &Type, source_module_path: &[String]) -> TokenStream { - let Type::Path(type_path) = ty else { - return quote! { #ty }; - }; + // ============================================================ + // Tests for BelongsTo/HasOne circular reference inline types + // ============================================================ - let Some(segment) = type_path.path.segments.last() else { - return quote! { #ty }; - }; + #[test] + #[serial] + fn test_generate_schema_type_code_belongs_to_circular_inline_optional() { + // Tests: BelongsTo with circular reference, optional field (is_optional = true) + use tempfile::TempDir; - let ident_str = segment.ident.to_string(); + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); - match ident_str.as_str() { - // Use vespera::chrono to avoid requiring users to add chrono dependency - "DateTimeWithTimeZone" => { - quote! { vespera::chrono::DateTime } - } - "DateTimeUtc" => quote! { vespera::chrono::DateTime }, - "DateTimeLocal" => quote! { vespera::chrono::DateTime }, - // Multipart types - resolve via vespera::multipart - "FieldData" => { - // Preserve inner generic: FieldData → vespera::multipart::FieldData - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { - let inner_args: Vec<_> = args - .args - .iter() - .map(|arg| { - if let syn::GenericArgument::Type(inner_ty) = arg { - let converted = - convert_seaorm_type_to_chrono(inner_ty, source_module_path); - quote! { #converted } - } else { - quote! { #arg } - } - }) - .collect(); - quote! { vespera::multipart::FieldData<#(#inner_args),*> } + // Create user.rs with Model that references memo (circular) + let user_model = r#" +#[sea_orm(table_name = "users")] +pub struct Model { + pub id: i32, + pub name: String, + pub memo: BelongsTo, +} +"#; + std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); + + // Create memo.rs with Model that references user (completing the circle) + let memo_model = r#" +#[sea_orm(table_name = "memos")] +pub struct Model { + pub id: i32, + pub title: String, + pub user_id: i32, + pub user: BelongsTo, +} +"#; + std::fs::write(models_dir.join("memo.rs"), memo_model).unwrap(); + + // Save original CARGO_MANIFEST_DIR + let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); + // SAFETY: This is a test that runs single-threaded + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + + // Generate schema from memo - has BelongsTo user which has circular ref back + let tokens = quote!(MemoSchema from crate::models::memo::Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let storage: HashMap = HashMap::new(); + + let result = generate_schema_type_code(&input, &storage); + + // Restore CARGO_MANIFEST_DIR + // SAFETY: This is a test that runs single-threaded + unsafe { + if let Some(dir) = original_manifest_dir { + std::env::set_var("CARGO_MANIFEST_DIR", dir); } else { - quote! { vespera::multipart::FieldData } + std::env::remove_var("CARGO_MANIFEST_DIR"); } } - "NamedTempFile" => quote! { vespera::tempfile::NamedTempFile }, - // Not a SeaORM datetime type - resolve to absolute path if needed - _ => resolve_type_to_absolute_path(ty, source_module_path), - } -} - -/// Convert a type to chrono equivalent, handling Option wrapper. -/// -/// If the type is `Option`, converts to `Option`. -/// If the type is just `SeaOrmType`, converts to `ChronoType`. -/// -/// Also resolves local types (like `MemoStatus`) to absolute paths -/// (like `crate::models::memo::MemoStatus`) using `source_module_path`. -pub fn convert_type_with_chrono(ty: &Type, source_module_path: &[String]) -> TokenStream { - // Check if it's Option - if let Type::Path(type_path) = ty - && let Some(segment) = type_path.path.segments.first() - && segment.ident == "Option" - { - // Extract the inner type from Option - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() - { - let converted_inner = convert_seaorm_type_to_chrono(inner_ty, source_module_path); - return quote! { Option<#converted_inner> }; - } - } - // Check if it's Vec - if let Type::Path(type_path) = ty - && let Some(segment) = type_path.path.segments.first() - && segment.ident == "Vec" - { - // Extract the inner type from Vec - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() - { - let converted_inner = convert_seaorm_type_to_chrono(inner_ty, source_module_path); - return quote! { Vec<#converted_inner> }; - } + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should have inline type definition for circular relation + assert!(output.contains("MemoSchema")); + assert!(output.contains("user")); + // BelongsTo is optional by default, so should have Option> + assert!(output.contains("Option < Box <")); } - // Not Option or Vec, convert directly - convert_seaorm_type_to_chrono(ty, source_module_path) -} - -/// Extract a named string value from a `sea_orm` attribute. -/// Shared helper for `extract_belongs_to_from_field`, `extract_relation_enum`, and `extract_via_rel`. -fn extract_sea_orm_attr_value(attrs: &[syn::Attribute], attr_name: &str) -> Option { - attrs.iter().find_map(|attr| { - if !attr.path().is_ident("sea_orm") { - return None; - } - - let mut found_value = None; - // Ignore parse errors — we just won't find the field if parsing fails - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident(attr_name) { - found_value = meta - .value() - .ok() - .and_then(|v| v.parse::().ok()) - .map(|lit| lit.value()); - } else if meta.input.peek(syn::Token![=]) { - // Consume value for other key=value pairs - // Required to allow parsing to continue to next item - drop( - meta.value() - .and_then(syn::parse::ParseBuffer::parse::), - ); - } - Ok(()) - }); - found_value - }) -} + #[test] + #[serial] + fn test_generate_schema_type_code_has_one_circular_inline_required() { + // Tests: HasOne with circular reference, required field (is_optional = false) + use tempfile::TempDir; -/// Extract the "from" field name from a `sea_orm` `belongs_to` attribute. -/// e.g., `#[sea_orm(belongs_to, from = "user_id", to = "id")]` -> `Some("user_id")` -/// Also handles: `#[sea_orm(belongs_to = "Entity", from = "user_id", to = "id")]` -pub fn extract_belongs_to_from_field(attrs: &[syn::Attribute]) -> Option { - extract_sea_orm_attr_value(attrs, "from") -} + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); -/// Extract the "`relation_enum`" value from a `sea_orm` attribute. -/// e.g., `#[sea_orm(belongs_to, relation_enum = "TargetUser", from = "target_user_id")]` -> Some("TargetUser") -/// -/// When `relation_enum` is present, it indicates that multiple relations to the same -/// Entity type exist, and we need to use the specific Relation enum variant for queries. -pub fn extract_relation_enum(attrs: &[syn::Attribute]) -> Option { - extract_sea_orm_attr_value(attrs, "relation_enum") + // Create profile.rs with Model that references user (circular) + let profile_model = r#" +#[sea_orm(table_name = "profiles")] +pub struct Model { + pub id: i32, + pub bio: String, + pub user: BelongsTo, } - -/// Extract the "`via_rel`" value from a `sea_orm` attribute. -/// e.g., `#[sea_orm(has_many, relation_enum = "TargetUser", via_rel = "TargetUser")]` -> Some("TargetUser") -/// -/// For `HasMany` relations with `relation_enum`, `via_rel` specifies which Relation variant -/// on the TARGET entity corresponds to this relation. This allows us to find the FK column. -pub fn extract_via_rel(attrs: &[syn::Attribute]) -> Option { - extract_sea_orm_attr_value(attrs, "via_rel") +"#; + std::fs::write(models_dir.join("profile.rs"), profile_model).unwrap(); + + // Create user.rs with Model that has HasOne profile + // HasOne with required FK becomes required (non-optional) + let user_model = r#" +#[sea_orm(table_name = "users")] +pub struct Model { + pub id: i32, + pub name: String, + pub profile_id: i32, + #[sea_orm(has_one = "super::profile::Entity", from = "profile_id")] + pub profile: HasOne, } - -/// Extract `default_value` from a `sea_orm` attribute. -/// e.g., `#[sea_orm(default_value = 0.7)]` -> `Some("0.7")` -/// e.g., `#[sea_orm(default_value = "active")]` -> `Some("active")` -pub fn extract_sea_orm_default_value(attrs: &[syn::Attribute]) -> Option { - for attr in attrs { - if !attr.path().is_ident("sea_orm") { - continue; - } - - // Use raw token string parsing to handle all literal types - // (parse_nested_meta can't easily parse non-string literals after `=`) - let syn::Meta::List(meta_list) = &attr.meta else { - continue; - }; - let tokens = meta_list.tokens.to_string(); - - if let Some(start) = tokens.find("default_value") { - let remaining = &tokens[start + "default_value".len()..]; - let remaining = remaining.trim_start(); - if let Some(after_eq) = remaining.strip_prefix('=') { - let value_str = after_eq.trim_start(); - // Extract value until comma or end of tokens - let end = value_str.find(',').unwrap_or(value_str.len()); - let raw_value = value_str[..end].trim(); - - if raw_value.is_empty() { - continue; - } - - // If quoted string, strip quotes and return inner value - if let Some(inner) = raw_value - .strip_prefix('"') - .and_then(|s| s.strip_suffix('"')) - { - return Some(inner.to_string()); - } - // Numeric, bool, or other literal — return as-is - return Some(raw_value.to_string()); +"#; + std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); + + // Save original CARGO_MANIFEST_DIR + let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); + // SAFETY: This is a test that runs single-threaded + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + + // Generate schema from user - has HasOne profile which has circular ref back + let tokens = quote!(UserSchema from crate::models::user::Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let storage: HashMap = HashMap::new(); + + let result = generate_schema_type_code(&input, &storage); + + // Restore CARGO_MANIFEST_DIR + // SAFETY: This is a test that runs single-threaded + unsafe { + if let Some(dir) = original_manifest_dir { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); } } - } - None -} - -/// Check if a `sea_orm(default_value)` is a SQL function (e.g., `"NOW()"`, `"CURRENT_TIMESTAMP()"`, `"UUID()"`) -/// that cannot be converted to a Rust default value. -/// -/// Detection: any value containing parentheses is treated as a SQL function call. -pub fn is_sql_function_default(value: &str) -> bool { - value.contains('(') -} -/// Check if a field has `#[sea_orm(primary_key)]`. -/// -/// Primary keys in SeaORM imply auto-increment by default, -/// meaning the database provides a value even when the client omits it. -pub fn has_sea_orm_primary_key(attrs: &[syn::Attribute]) -> bool { - for attr in attrs { - if !attr.path().is_ident("sea_orm") { - continue; - } - let syn::Meta::List(meta_list) = &attr.meta else { - continue; - }; - let tokens = meta_list.tokens.to_string(); - if tokens.contains("primary_key") { - return true; - } - } - false + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should have inline type definition for circular relation + assert!(output.contains("UserSchema")); + assert!(output.contains("profile")); + // HasOne with required FK should have Box<...> (not Option>) + assert!(output.contains("Box <")); + } + + #[test] + #[serial] + fn test_generate_schema_type_code_belongs_to_circular_inline_required_file() { + // Tests: BelongsTo with circular reference AND required FK (is_optional = false) + // This requires file-based lookup with: + // 1. #[sea_orm(from = "required_fk")] where required_fk is NOT Option + // 2. Circular reference between two models + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + + // Create user.rs with Model that references memo (circular) + let user_model = r#" +#[sea_orm(table_name = "users")] +pub struct Model { + pub id: i32, + pub name: String, + pub memo_id: i32, + #[sea_orm(belongs_to, from = "memo_id", to = "id")] + pub memo: BelongsTo, } - -/// Check if a field in the struct is optional (Option). -pub fn is_field_optional_in_struct(struct_item: &syn::ItemStruct, field_name: &str) -> bool { - if let syn::Fields::Named(fields_named) = &struct_item.fields { - for field in &fields_named.named { - if let Some(ident) = &field.ident - && ident == field_name - { - return is_option_type(&field.ty); - } - } - } - false +"#; + std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); + + // Create memo.rs with Model that references user (completing the circle) + // Note: using flag-style `belongs_to` with `from = "user_id"` + let memo_model = r#" +#[sea_orm(table_name = "memos")] +pub struct Model { + pub id: i32, + pub title: String, + pub user_id: i32, + #[sea_orm(belongs_to, from = "user_id", to = "id")] + pub user: BelongsTo, } - -/// Convert a `SeaORM` relation type to a Schema type AND return relation info. -/// -/// - `#[sea_orm(has_one)]` -> Always `Option>` -/// - `#[sea_orm(has_many)]` -> Always `Vec` -/// - `#[sea_orm(belongs_to, from = "field")]`: -/// - If `from` field is `Option` -> `Option>` -/// - If `from` field is required -> `Box` -/// -/// The `source_module_path` is used to resolve relative paths like `super::`. -/// e.g., if source is `crate::models::memo::Model`, module path is `crate::models::memo` -/// -/// Returns None if the type is not a relation type or conversion fails. -/// Returns (`TokenStream`, `RelationFieldInfo`) on success for use in `from_model` generation. -#[allow(clippy::too_many_lines)] -pub fn convert_relation_type_to_schema_with_info( - ty: &Type, - field_attrs: &[syn::Attribute], - parsed_struct: &syn::ItemStruct, - source_module_path: &[String], - field_name: syn::Ident, -) -> Option<(TokenStream, RelationFieldInfo)> { - let Type::Path(type_path) = ty else { - return None; - }; - - let segment = type_path.path.segments.last()?; - let ident_str = segment.ident.to_string(); - - // Check if this is a relation type with generic argument - let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { - return None; - }; - - // Get the inner Entity type - let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() else { - return None; - }; - - // Extract the path and convert to absolute Schema path - let Type::Path(inner_path) = inner_ty else { - return None; - }; - - // Collect segments as strings - let segments: Vec = inner_path - .path - .segments - .iter() - .map(|s| s.ident.to_string()) - .collect(); - - // Convert path to absolute, resolving `super::` relative to source module - let absolute_segments: Vec = if !segments.is_empty() && segments[0] == "super" { - let super_count = segments.iter().take_while(|s| *s == "super").count(); - let parent_path_len = source_module_path.len().saturating_sub(super_count); - let mut abs = Vec::with_capacity(parent_path_len + segments.len() - super_count); - abs.extend_from_slice(&source_module_path[..parent_path_len]); - for seg in segments.iter().skip(super_count) { - if seg == "Entity" { - abs.push("Schema".to_string()); - } else { - abs.push(seg.clone()); - } - } - abs - } else if !segments.is_empty() && segments[0] == "crate" { - segments - .iter() - .map(|s| { - if s == "Entity" { - "Schema".to_string() - } else { - s.clone() - } - }) - .collect() - } else { - let parent_path_len = source_module_path.len().saturating_sub(1); - let mut abs = Vec::with_capacity(parent_path_len + segments.len()); - abs.extend_from_slice(&source_module_path[..parent_path_len]); - for seg in &segments { - if seg == "Entity" { - abs.push("Schema".to_string()); +"#; + std::fs::write(models_dir.join("memo.rs"), memo_model).unwrap(); + + // Save original CARGO_MANIFEST_DIR + let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); + // SAFETY: This is a test that runs single-threaded + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + + // Generate schema from memo - has BelongsTo user which has circular ref back + // The user_id field is required (not Option), so is_optional = false + // This should generate Box<...> instead of Option> + let tokens = quote!(MemoSchema from crate::models::memo::Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let storage: HashMap = HashMap::new(); + + let result = generate_schema_type_code(&input, &storage); + + // Restore CARGO_MANIFEST_DIR + // SAFETY: This is a test that runs single-threaded + unsafe { + if let Some(dir) = original_manifest_dir { + std::env::set_var("CARGO_MANIFEST_DIR", dir); } else { - abs.push(seg.clone()); + std::env::remove_var("CARGO_MANIFEST_DIR"); } } - abs - }; - - // Build the absolute path as tokens - let path_idents: Vec = absolute_segments - .iter() - .map(|s| syn::Ident::new(s, proc_macro2::Span::call_site())) - .collect(); - let schema_path = quote! { #(#path_idents)::* }; - - // Convert based on relation type - match ident_str.as_str() { - "HasOne" => { - // HasOne -> Check FK field to determine optionality - // If FK is Option -> relation is optional: Option> - // If FK is required -> relation is required: Box - let fk_field = extract_belongs_to_from_field(field_attrs); - let relation_enum = extract_relation_enum(field_attrs); - let is_optional = fk_field - .as_ref() - .is_none_or(|f| is_field_optional_in_struct(parsed_struct, f)); // Default to optional if we can't determine - - let converted = if is_optional { - quote! { Option> } - } else { - quote! { Box<#schema_path> } - }; - let info = RelationFieldInfo { - field_name, - relation_type: "HasOne".to_string(), - schema_path, - is_optional, - inline_type_info: None, // Will be populated later if circular - relation_enum, - fk_column: fk_field, - via_rel: None, // Not used for HasOne - }; - Some((converted, info)) - } - "HasMany" => { - let relation_enum = extract_relation_enum(field_attrs); - let via_rel = extract_via_rel(field_attrs); - let converted = quote! { Vec<#schema_path> }; - let info = RelationFieldInfo { - field_name, - relation_type: "HasMany".to_string(), - schema_path, - is_optional: false, - inline_type_info: None, // Will be populated later if circular - relation_enum, - fk_column: None, // HasMany doesn't have FK on this side - via_rel, // Used to find FK on target entity - }; - Some((converted, info)) - } - "BelongsTo" => { - // BelongsTo -> Check FK field to determine optionality - // If FK is Option -> relation is optional: Option> - // If FK is required -> relation is required: Box - let fk_field = extract_belongs_to_from_field(field_attrs); - let relation_enum = extract_relation_enum(field_attrs); - let is_optional = fk_field - .as_ref() - .is_none_or(|f| is_field_optional_in_struct(parsed_struct, f)); // Default to optional if we can't determine - - let converted = if is_optional { - quote! { Option> } - } else { - quote! { Box<#schema_path> } - }; - let info = RelationFieldInfo { - field_name, - relation_type: "BelongsTo".to_string(), - schema_path, - is_optional, - inline_type_info: None, // Will be populated later if circular - relation_enum, - fk_column: fk_field, - via_rel: None, // Not used for BelongsTo - }; - Some((converted, info)) - } - _ => None, - } -} - -/// Convert a SeaORM relation type to a Schema type. -/// -/// - `#[sea_orm(has_one)]` -> Always `Option>` -/// - `#[sea_orm(has_many)]` -> Always `Vec` -/// - `#[sea_orm(belongs_to, from = "field")]`: -/// - If `from` field is `Option` -> `Option>` -/// - If `from` field is required -> `Box` -/// -#[cfg(test)] -mod tests { - use rstest::rstest; - - use super::*; - #[rstest] - #[case( - "DateTimeWithTimeZone", - "vespera :: chrono :: DateTime < vespera :: chrono :: FixedOffset >" - )] - #[case( - "DateTimeUtc", - "vespera :: chrono :: DateTime < vespera :: chrono :: Utc >" - )] - #[case( - "DateTimeLocal", - "vespera :: chrono :: DateTime < vespera :: chrono :: Local >" - )] - fn test_convert_seaorm_type_to_chrono(#[case] input: &str, #[case] expected_contains: &str) { - let ty: syn::Type = syn::parse_str(input).unwrap(); - let tokens = convert_seaorm_type_to_chrono(&ty, &[]); + assert!(result.is_ok(), "Should generate schema: {:?}", result.err()); + let (tokens, _metadata) = result.unwrap(); let output = tokens.to_string(); - assert!(output.contains(expected_contains)); - } - - #[test] - fn test_convert_seaorm_type_to_chrono_non_path_type() { - let ty: syn::Type = syn::parse_str("&str").unwrap(); - let tokens = convert_seaorm_type_to_chrono(&ty, &[]); - let output = tokens.to_string(); - assert!(output.contains("& str")); - } - - #[test] - fn test_convert_seaorm_type_to_chrono_regular_type() { - let ty: syn::Type = syn::parse_str("String").unwrap(); - let tokens = convert_seaorm_type_to_chrono(&ty, &[]); - let output = tokens.to_string(); - assert_eq!(output.trim(), "String"); - } - - #[test] - fn test_convert_type_with_chrono_option_datetime() { - let ty: syn::Type = syn::parse_str("Option").unwrap(); - let tokens = convert_type_with_chrono(&ty, &[]); - let output = tokens.to_string(); - assert!(output.contains("Option <")); - assert!(output.contains("vespera :: chrono :: DateTime")); - } - - #[test] - fn test_convert_type_with_chrono_vec_datetime() { - let ty: syn::Type = syn::parse_str("Vec").unwrap(); - let tokens = convert_type_with_chrono(&ty, &[]); - let output = tokens.to_string(); - assert!(output.contains("Vec <")); - assert!(output.contains("vespera :: chrono :: DateTime")); - } - - #[test] - fn test_convert_type_with_chrono_plain_type() { - let ty: syn::Type = syn::parse_str("i32").unwrap(); - let tokens = convert_type_with_chrono(&ty, &[]); - let output = tokens.to_string(); - assert_eq!(output.trim(), "i32"); - } - - #[test] - fn test_extract_belongs_to_from_field_with_from() { - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(belongs_to, from = "user_id", to = "id")] - )]; - let result = extract_belongs_to_from_field(&attrs); - assert_eq!(result, Some("user_id".to_string())); - } - - #[test] - fn test_extract_belongs_to_from_field_without_from() { - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(belongs_to, to = "id")] - )]; - let result = extract_belongs_to_from_field(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_extract_belongs_to_from_field_no_sea_orm_attr() { - let attrs: Vec = vec![syn::parse_quote!(#[serde(skip)])]; - let result = extract_belongs_to_from_field(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_extract_belongs_to_from_field_empty_attrs() { - let result = extract_belongs_to_from_field(&[]); - assert_eq!(result, None); - } - - #[test] - fn test_extract_relation_enum_with_value() { - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(belongs_to, relation_enum = "TargetUser", from = "target_user_id", to = "id")] - )]; - let result = extract_relation_enum(&attrs); - assert_eq!(result, Some("TargetUser".to_string())); - } - - #[test] - fn test_extract_relation_enum_without_relation_enum() { - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(belongs_to, from = "user_id", to = "id")] - )]; - let result = extract_relation_enum(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_extract_relation_enum_no_sea_orm_attr() { - let attrs: Vec = vec![syn::parse_quote!(#[serde(skip)])]; - let result = extract_relation_enum(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_extract_relation_enum_empty_attrs() { - let result = extract_relation_enum(&[]); - assert_eq!(result, None); - } - - #[test] - fn test_is_field_optional_in_struct_optional() { - let struct_item: syn::ItemStruct = syn::parse_str( - r" - struct Model { - id: i32, - user_id: Option, - } - ", - ) - .unwrap(); - assert!(is_field_optional_in_struct(&struct_item, "user_id")); - } - - #[test] - fn test_is_field_optional_in_struct_required() { - let struct_item: syn::ItemStruct = syn::parse_str( - r" - struct Model { - id: i32, - user_id: i32, - } - ", - ) - .unwrap(); - assert!(!is_field_optional_in_struct(&struct_item, "user_id")); - } - - #[test] - fn test_is_field_optional_in_struct_field_not_found() { - let struct_item: syn::ItemStruct = syn::parse_str( - r" - struct Model { - id: i32, - } - ", - ) - .unwrap(); - assert!(!is_field_optional_in_struct(&struct_item, "nonexistent")); - } - - #[test] - fn test_is_field_optional_in_struct_tuple_struct() { - let struct_item: syn::ItemStruct = - syn::parse_str("struct TupleStruct(i32, Option);").unwrap(); - assert!(!is_field_optional_in_struct(&struct_item, "0")); - } - - // ========================================================================= - // Tests for convert_seaorm_type_to_chrono edge cases - // ========================================================================= - - #[test] - fn test_convert_seaorm_type_to_chrono_empty_path() { - let ty = syn::Type::Path(syn::TypePath { - qself: None, - path: syn::Path { - leading_colon: None, - segments: syn::punctuated::Punctuated::new(), - }, - }); - let tokens = convert_seaorm_type_to_chrono(&ty, &[]); - // Should return the original type unchanged - assert!(tokens.to_string().is_empty() || tokens.to_string().trim().is_empty()); - } - - // ========================================================================= - // Tests for FieldData/NamedTempFile type conversion - // ========================================================================= - - #[test] - fn test_convert_seaorm_type_field_data_with_generic() { - // FieldData → vespera::multipart::FieldData - let ty: syn::Type = syn::parse_str("FieldData").unwrap(); - let tokens = convert_seaorm_type_to_chrono(&ty, &[]); - let output = tokens.to_string(); - assert!( - output.contains("vespera :: multipart :: FieldData"), - "Should resolve FieldData via vespera::multipart: {output}" - ); + // Should have inline type definition for circular relation assert!( - output.contains("vespera :: tempfile :: NamedTempFile"), - "Should resolve inner NamedTempFile via vespera re-export: {output}" + output.contains("MemoSchema"), + "Should contain MemoSchema: {output}" ); - } - - #[test] - fn test_convert_seaorm_type_field_data_without_generic() { - // FieldData (no generics) → vespera::multipart::FieldData - let ty: syn::Type = syn::parse_str("FieldData").unwrap(); - let tokens = convert_seaorm_type_to_chrono(&ty, &[]); - let output = tokens.to_string(); assert!( - output.contains("vespera :: multipart :: FieldData"), - "Should resolve bare FieldData: {output}" + output.contains("user"), + "Should contain user field: {output}" ); - // Should NOT contain nested generic + // BelongsTo with required FK (user_id: i32) should generate Box<...> not Option> assert!( - !output.contains("NamedTempFile"), - "Bare FieldData should not have NamedTempFile: {output}" - ); - } - - #[test] - fn test_convert_seaorm_type_field_data_with_non_type_generic() { - // FieldData with a non-Type generic arg (e.g., lifetime) should use fallback quote - let ty: syn::Type = syn::parse_str("FieldData<'a>").unwrap(); - let tokens = convert_seaorm_type_to_chrono(&ty, &[]); - let output = tokens.to_string(); - assert!( - output.contains("vespera :: multipart :: FieldData"), - "Should still resolve FieldData: {output}" - ); - } - - #[test] - fn test_convert_seaorm_type_named_temp_file() { - // NamedTempFile → vespera::tempfile::NamedTempFile - let ty: syn::Type = syn::parse_str("NamedTempFile").unwrap(); - let tokens = convert_seaorm_type_to_chrono(&ty, &[]); - let output = tokens.to_string(); - assert_eq!(output.trim(), "vespera :: tempfile :: NamedTempFile"); - } - - #[test] - fn test_convert_type_with_chrono_json_alias_uses_public_value_path() { - let ty: syn::Type = syn::parse_str("Json").unwrap(); - let tokens = convert_type_with_chrono( - &ty, - &[ - "crate".to_string(), - "models".to_string(), - "json_case".to_string(), - ], - ); - let output = tokens.to_string(); - assert_eq!(output.trim(), "vespera :: serde_json :: Value"); - } - - // ========================================================================= - // Tests for convert_relation_type_to_schema_with_info - // ========================================================================= - - fn make_test_struct(def: &str) -> syn::ItemStruct { - syn::parse_str(def).unwrap() - } - - #[test] - fn test_convert_relation_type_to_schema_with_info_non_path_type() { - let ty: syn::Type = syn::parse_str("&str").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32 }"); - let field_name = syn::Ident::new("user", proc_macro2::Span::call_site()); - let result = - convert_relation_type_to_schema_with_info(&ty, &[], &struct_item, &[], field_name); - assert!(result.is_none()); - } - - #[test] - fn test_convert_relation_type_to_schema_with_info_empty_segments() { - let ty = syn::Type::Path(syn::TypePath { - qself: None, - path: syn::Path { - leading_colon: None, - segments: syn::punctuated::Punctuated::new(), - }, - }); - let struct_item = make_test_struct("struct Model { id: i32 }"); - let field_name = syn::Ident::new("user", proc_macro2::Span::call_site()); - let result = - convert_relation_type_to_schema_with_info(&ty, &[], &struct_item, &[], field_name); - assert!(result.is_none()); - } - - #[test] - fn test_convert_relation_type_to_schema_with_info_no_angle_brackets() { - let ty: syn::Type = syn::parse_str("HasOne").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32 }"); - let field_name = syn::Ident::new("user", proc_macro2::Span::call_site()); - let result = - convert_relation_type_to_schema_with_info(&ty, &[], &struct_item, &[], field_name); - assert!(result.is_none()); - } - - #[test] - fn test_convert_relation_type_to_schema_with_info_non_type_generic() { - // Test with lifetime generic instead of type - let ty: syn::Type = syn::parse_str("HasOne<'a>").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32 }"); - let field_name = syn::Ident::new("user", proc_macro2::Span::call_site()); - let result = - convert_relation_type_to_schema_with_info(&ty, &[], &struct_item, &[], field_name); - assert!(result.is_none()); - } - - #[test] - fn test_convert_relation_type_to_schema_with_info_non_path_inner() { - // Inner type is a reference, not a path - let ty: syn::Type = syn::parse_str("HasOne<&str>").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32 }"); - let field_name = syn::Ident::new("user", proc_macro2::Span::call_site()); - let result = - convert_relation_type_to_schema_with_info(&ty, &[], &struct_item, &[], field_name); - assert!(result.is_none()); - } - - #[test] - fn test_convert_relation_type_to_schema_with_info_has_one_optional() { - let ty: syn::Type = syn::parse_str("HasOne").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32, user_id: Option }"); - let attrs: Vec = - vec![syn::parse_quote!(#[sea_orm(belongs_to, from = "user_id")])]; - let field_name = syn::Ident::new("user", proc_macro2::Span::call_site()); - let module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - let result = convert_relation_type_to_schema_with_info( - &ty, - &attrs, - &struct_item, - &module_path, - field_name, - ); - assert!(result.is_some()); - let (tokens, info) = result.unwrap(); - assert_eq!(info.relation_type, "HasOne"); - assert!(info.is_optional); - assert!(tokens.to_string().contains("Option")); - } - - #[test] - fn test_convert_relation_type_to_schema_with_info_has_one_required() { - let ty: syn::Type = syn::parse_str("HasOne").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32, user_id: i32 }"); - let attrs: Vec = - vec![syn::parse_quote!(#[sea_orm(belongs_to, from = "user_id")])]; - let field_name = syn::Ident::new("user", proc_macro2::Span::call_site()); - let module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - let result = convert_relation_type_to_schema_with_info( - &ty, - &attrs, - &struct_item, - &module_path, - field_name, + output.contains("pub user : Box <"), + "BelongsTo with required FK should generate Box<>, not Option>. Output: {output}" ); - assert!(result.is_some()); - let (tokens, info) = result.unwrap(); - assert_eq!(info.relation_type, "HasOne"); - assert!(!info.is_optional); - assert!(tokens.to_string().contains("Box")); - assert!(!tokens.to_string().contains("Option")); } #[test] - fn test_convert_relation_type_to_schema_with_info_has_one_no_fk() { - let ty: syn::Type = syn::parse_str("HasOne").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32 }"); - let field_name = syn::Ident::new("user", proc_macro2::Span::call_site()); - let module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - // No attributes, so defaults to optional - let result = convert_relation_type_to_schema_with_info( - &ty, - &[], - &struct_item, - &module_path, - field_name, - ); - assert!(result.is_some()); - let (tokens, info) = result.unwrap(); - assert!(info.is_optional); // Default when FK not determinable - assert!(tokens.to_string().contains("Option")); - } + fn test_seaorm_relation_required_fk_directly() { + // Test the convert_relation_type_to_schema_with_info function directly + // to verify is_optional = false when FK is required + use crate::schema_macro::seaorm::{ + convert_relation_type_to_schema_with_info, extract_belongs_to_from_field, + is_field_optional_in_struct, + }; - #[test] - fn test_convert_relation_type_to_schema_with_info_has_many() { - let ty: syn::Type = syn::parse_str("HasMany").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32 }"); - let field_name = syn::Ident::new("memos", proc_macro2::Span::call_site()); - let module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - let result = convert_relation_type_to_schema_with_info( - &ty, - &[], - &struct_item, - &module_path, - field_name, - ); - assert!(result.is_some()); - let (tokens, info) = result.unwrap(); - assert_eq!(info.relation_type, "HasMany"); - assert!(!info.is_optional); - assert!(tokens.to_string().contains("Vec")); - } + // Use the same attribute format that works in seaorm tests: belongs_to (flag), not belongs_to = "..." + let struct_def = r#" +#[sea_orm(table_name = "memos")] +pub struct Model { + pub id: i32, + pub user_id: i32, + #[sea_orm(belongs_to, from = "user_id", to = "id")] + pub user: BelongsTo, +} +"#; + let parsed_struct: syn::ItemStruct = syn::parse_str(struct_def).unwrap(); - #[test] - fn test_convert_relation_type_to_schema_with_info_belongs_to_optional() { - let ty: syn::Type = syn::parse_str("BelongsTo").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32, user_id: Option }"); - let attrs: Vec = - vec![syn::parse_quote!(#[sea_orm(belongs_to, from = "user_id")])]; - let field_name = syn::Ident::new("user", proc_macro2::Span::call_site()); - let module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - let result = convert_relation_type_to_schema_with_info( - &ty, - &attrs, - &struct_item, - &module_path, - field_name, - ); - assert!(result.is_some()); - let (tokens, info) = result.unwrap(); - assert_eq!(info.relation_type, "BelongsTo"); - assert!(info.is_optional); - assert!(tokens.to_string().contains("Option")); - } + // Get the user field + let syn::Fields::Named(fields_named) = &parsed_struct.fields else { + panic!("Expected named fields") + }; - #[test] - fn test_convert_relation_type_to_schema_with_info_belongs_to_required() { - let ty: syn::Type = syn::parse_str("BelongsTo").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32, user_id: i32 }"); - let attrs: Vec = - vec![syn::parse_quote!(#[sea_orm(belongs_to, from = "user_id")])]; - let field_name = syn::Ident::new("user", proc_macro2::Span::call_site()); - let module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - let result = convert_relation_type_to_schema_with_info( - &ty, - &attrs, - &struct_item, - &module_path, - field_name, + let user_field = fields_named + .named + .iter() + .find(|f| f.ident.as_ref().is_some_and(|i| i == "user")) + .expect("user field not found"); + + // Debug: Check if extract_belongs_to_from_field works + let fk_field = extract_belongs_to_from_field(&user_field.attrs); + assert_eq!( + fk_field, + Some("user_id".to_string()), + "Should extract FK field from attribute" ); - assert!(result.is_some()); - let (tokens, info) = result.unwrap(); - assert_eq!(info.relation_type, "BelongsTo"); - assert!(!info.is_optional); - assert!(!tokens.to_string().contains("Option")); - } - #[test] - fn test_convert_relation_type_to_schema_with_info_unknown_relation() { - let ty: syn::Type = syn::parse_str("SomeOtherType").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32 }"); - let field_name = syn::Ident::new("user", proc_macro2::Span::call_site()); - let result = - convert_relation_type_to_schema_with_info(&ty, &[], &struct_item, &[], field_name); - assert!(result.is_none()); - } - - #[test] - fn test_convert_relation_type_to_schema_with_info_super_path() { - let ty: syn::Type = syn::parse_str("HasMany").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32 }"); - let field_name = syn::Ident::new("memos", proc_macro2::Span::call_site()); - let module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - let result = convert_relation_type_to_schema_with_info( - &ty, - &[], - &struct_item, - &module_path, - field_name, - ); - assert!(result.is_some()); - let (tokens, _info) = result.unwrap(); - let output = tokens.to_string(); - // super:: should resolve: crate::models::user -> crate::models::memo - assert!(output.contains("crate")); - assert!(output.contains("models")); - assert!(output.contains("memo")); - assert!(output.contains("Schema")); - } + // Debug: Check if is_field_optional_in_struct works + let is_fk_optional = is_field_optional_in_struct(&parsed_struct, "user_id"); + assert!(!is_fk_optional, "user_id: i32 should not be optional"); - #[test] - fn test_convert_relation_type_to_schema_with_info_crate_path() { - let ty: syn::Type = syn::parse_str("HasMany").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32 }"); - let field_name = syn::Ident::new("memos", proc_macro2::Span::call_site()); - let module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; let result = convert_relation_type_to_schema_with_info( - &ty, - &[], - &struct_item, - &module_path, - field_name, + &user_field.ty, + &user_field.attrs, + &parsed_struct, + &[ + "crate".to_string(), + "models".to_string(), + "memo".to_string(), + ], + user_field.ident.clone().unwrap(), ); - assert!(result.is_some()); - let (tokens, _info) = result.unwrap(); - let output = tokens.to_string(); - // crate:: path should preserve and replace Entity with Schema - assert!(output.contains("crate")); - assert!(output.contains("models")); - assert!(output.contains("memo")); - assert!(output.contains("Schema")); - assert!(!output.contains("Entity")); - } - #[test] - fn test_convert_relation_type_to_schema_with_info_relative_path() { - let ty: syn::Type = syn::parse_str("HasOne").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32 }"); - let field_name = syn::Ident::new("user", proc_macro2::Span::call_site()); - let module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - let result = convert_relation_type_to_schema_with_info( - &ty, - &[], - &struct_item, - &module_path, - field_name, + assert!(result.is_some(), "Should convert BelongsTo relation"); + let (_, rel_info) = result.unwrap(); + assert_eq!(rel_info.relation_type, "BelongsTo"); + // The FK field user_id is i32 (not Option), so is_optional should be false + assert!( + !rel_info.is_optional, + "BelongsTo with required FK (user_id: i32) should have is_optional = false" ); - assert!(result.is_some()); - let (tokens, _info) = result.unwrap(); - let output = tokens.to_string(); - // Relative path should be resolved relative to parent - assert!(output.contains("crate")); - assert!(output.contains("models")); - assert!(output.contains("user")); - assert!(output.contains("Schema")); } - // ========================================================================= - // Tests for extract_via_rel - // ========================================================================= - #[test] - fn test_extract_via_rel_with_value() { - // Tests: via_rel = "..." found - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(has_many, via_rel = "TargetUser")] - )]; - let result = extract_via_rel(&attrs); - assert_eq!(result, Some("TargetUser".to_string())); - } + fn test_extract_belongs_to_from_field_with_equals_value() { + // Test that extract_belongs_to_from_field works with belongs_to = "..." format + use crate::schema_macro::seaorm::extract_belongs_to_from_field; - #[test] - fn test_extract_via_rel_with_relation_enum() { - // Tests: via_rel alongside other attributes - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(has_many, relation_enum = "TargetUserNotifications", via_rel = "TargetUser")] - )]; - let result = extract_via_rel(&attrs); - assert_eq!(result, Some("TargetUser".to_string())); - } - - #[test] - fn test_extract_via_rel_without_via_rel() { - // Tests: No via_rel attribute present - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(has_many, relation_enum = "Memos")] - )]; - let result = extract_via_rel(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_extract_via_rel_non_sea_orm_attr() { - // Tests: Non-sea_orm attribute returns None - let attrs: Vec = vec![syn::parse_quote!(#[serde(skip)])]; - let result = extract_via_rel(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_extract_via_rel_empty_attrs() { - // Tests: Empty attributes - let result = extract_via_rel(&[]); - assert_eq!(result, None); - } - - #[test] - fn test_extract_via_rel_with_other_key_value_pairs() { - // Tests: Other key=value pairs are consumed without error - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(belongs_to = "super::user::Entity", from = "user_id", to = "id", via_rel = "Author")] - )]; - let result = extract_via_rel(&attrs); - assert_eq!(result, Some("Author".to_string())); - } - - #[test] - fn test_extract_via_rel_multiple_sea_orm_attrs() { - // Tests: Multiple sea_orm attributes, via_rel in second one - let attrs: Vec = vec![ - syn::parse_quote!(#[sea_orm(has_many)]), - syn::parse_quote!(#[sea_orm(via_rel = "Comments")]), - ]; - let result = extract_via_rel(&attrs); - assert_eq!(result, Some("Comments".to_string())); - } - - #[test] - fn test_extract_sea_orm_default_value_float() { - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(default_value = 0.7)] - )]; - let result = extract_sea_orm_default_value(&attrs); - assert_eq!(result, Some("0.7".to_string())); - } - - #[test] - fn test_extract_sea_orm_default_value_int() { - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(default_value = 42)] - )]; - let result = extract_sea_orm_default_value(&attrs); - assert_eq!(result, Some("42".to_string())); - } - - #[test] - fn test_extract_sea_orm_default_value_string() { - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(default_value = "active")] - )]; - let result = extract_sea_orm_default_value(&attrs); - assert_eq!(result, Some("active".to_string())); - } - - #[test] - fn test_extract_sea_orm_default_value_bool() { - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(default_value = true)] + // Format 1: belongs_to (flag style) - known to work + let attrs1: Vec = vec![syn::parse_quote!( + #[sea_orm(belongs_to, from = "user_id", to = "id")] )]; - let result = extract_sea_orm_default_value(&attrs); - assert_eq!(result, Some("true".to_string())); - } + let result1 = extract_belongs_to_from_field(&attrs1); + assert_eq!( + result1, + Some("user_id".to_string()), + "Flag style should work" + ); - #[test] - fn test_extract_sea_orm_default_value_with_other_attrs() { - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(column_type = "Decimal(Some((10, 2)))", default_value = 0.7)] + // Format 2: belongs_to = "..." (value style) - testing this + let attrs2: Vec = vec![syn::parse_quote!( + #[sea_orm(belongs_to = "super::user::Entity", from = "user_id", to = "id")] )]; - let result = extract_sea_orm_default_value(&attrs); - assert_eq!(result, Some("0.7".to_string())); - } - - #[test] - fn test_extract_sea_orm_default_value_none() { - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(column_type = "Text")] - )]; - let result = extract_sea_orm_default_value(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_extract_sea_orm_default_value_non_sea_orm_attr() { - let attrs: Vec = vec![syn::parse_quote!(#[serde(default)])]; - let result = extract_sea_orm_default_value(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_extract_sea_orm_default_value_empty_attrs() { - let result = extract_sea_orm_default_value(&[]); - assert_eq!(result, None); - } - - #[test] - fn test_extract_sea_orm_default_value_non_list_meta() { - // #[sea_orm] as a path attribute (non-Meta::List) — line 222 branch - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm])]; - let result = extract_sea_orm_default_value(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_extract_sea_orm_default_value_empty_value_after_equals() { - // default_value = , (empty value) — line 236 branch - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = )])]; - let result = extract_sea_orm_default_value(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_extract_sea_orm_default_value_no_default_value_key() { - let attrs: Vec = - vec![syn::parse_quote!(#[sea_orm(primary_key, auto_increment)])]; - let result = extract_sea_orm_default_value(&attrs); - assert_eq!(result, None); - } - - // ========================================================================= - // Tests for is_sql_function_default - // ========================================================================= - - #[rstest] - #[case("NOW()", true)] - #[case("CURRENT_TIMESTAMP()", true)] - #[case("UUID()", true)] - #[case("gen_random_uuid()", true)] - #[case("0.7", false)] - #[case("42", false)] - #[case("true", false)] - #[case("draft", false)] - #[case("active", false)] - fn test_is_sql_function_default(#[case] value: &str, #[case] expected: bool) { - assert_eq!(is_sql_function_default(value), expected); - } - - // ========================================================================= - // Tests for has_sea_orm_primary_key - // ========================================================================= - - #[test] - fn test_has_sea_orm_primary_key_true() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(primary_key)])]; - assert!(has_sea_orm_primary_key(&attrs)); - } - - #[test] - fn test_has_sea_orm_primary_key_with_other_attrs() { - let attrs: Vec = - vec![syn::parse_quote!(#[sea_orm(primary_key, default_value = "gen_random_uuid()")])]; - assert!(has_sea_orm_primary_key(&attrs)); - } - - #[test] - fn test_has_sea_orm_primary_key_false() { - let attrs: Vec = - vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; - assert!(!has_sea_orm_primary_key(&attrs)); - } - - #[test] - fn test_has_sea_orm_primary_key_no_sea_orm_attr() { - let attrs: Vec = vec![syn::parse_quote!(#[serde(default)])]; - assert!(!has_sea_orm_primary_key(&attrs)); - } - - #[test] - fn test_has_sea_orm_primary_key_empty_attrs() { - assert!(!has_sea_orm_primary_key(&[])); - } - - #[test] - fn test_has_sea_orm_primary_key_non_list_meta() { - // #[sea_orm = "value"] is a NameValue meta, not a List — should be skipped - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm = "something"])]; - assert!(!has_sea_orm_primary_key(&attrs)); + let result2 = extract_belongs_to_from_field(&attrs2); + assert_eq!( + result2, + Some("user_id".to_string()), + "Value style should also work" + ); } } diff --git a/crates/vespera_macro/src/schema_macro/seaorm/attrs.rs b/crates/vespera_macro/src/schema_macro/seaorm/attrs.rs new file mode 100644 index 00000000..b4264599 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/seaorm/attrs.rs @@ -0,0 +1,347 @@ +/// Extract a named string value from a `sea_orm` attribute. +fn extract_sea_orm_attr_value(attrs: &[syn::Attribute], attr_name: &str) -> Option { + attrs.iter().find_map(|attr| { + if !attr.path().is_ident("sea_orm") { + return None; + } + + let mut found_value = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident(attr_name) { + found_value = meta + .value() + .ok() + .and_then(|v| v.parse::().ok()) + .map(|lit| lit.value()); + } else if meta.input.peek(syn::Token![=]) { + drop( + meta.value() + .and_then(syn::parse::ParseBuffer::parse::), + ); + } + Ok(()) + }); + found_value + }) +} + +/// Extract the `from` field name from a `sea_orm` relation attribute. +pub fn extract_belongs_to_from_field(attrs: &[syn::Attribute]) -> Option { + extract_sea_orm_attr_value(attrs, "from") +} + +/// Extract the `relation_enum` value from a `sea_orm` attribute. +pub fn extract_relation_enum(attrs: &[syn::Attribute]) -> Option { + extract_sea_orm_attr_value(attrs, "relation_enum") +} + +/// Extract the `via_rel` value from a `sea_orm` attribute. +pub fn extract_via_rel(attrs: &[syn::Attribute]) -> Option { + extract_sea_orm_attr_value(attrs, "via_rel") +} + +/// Extract `default_value` from a `sea_orm` attribute. +pub fn extract_sea_orm_default_value(attrs: &[syn::Attribute]) -> Option { + for attr in attrs { + if !attr.path().is_ident("sea_orm") { + continue; + } + + let syn::Meta::List(meta_list) = &attr.meta else { + continue; + }; + let tokens = meta_list.tokens.to_string(); + + if let Some(start) = tokens.find("default_value") { + let remaining = &tokens[start + "default_value".len()..]; + let remaining = remaining.trim_start(); + if let Some(after_eq) = remaining.strip_prefix('=') { + let value_str = after_eq.trim_start(); + let end = value_str.find(',').unwrap_or(value_str.len()); + let raw_value = value_str[..end].trim(); + + if raw_value.is_empty() { + continue; + } + + if let Some(inner) = raw_value + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + { + return Some(inner.to_string()); + } + return Some(raw_value.to_string()); + } + } + } + None +} + +/// Check if a `sea_orm(default_value)` is a SQL function. +pub fn is_sql_function_default(value: &str) -> bool { + value.contains('(') +} + +/// Check if a field has `#[sea_orm(primary_key)]`. +pub fn has_sea_orm_primary_key(attrs: &[syn::Attribute]) -> bool { + for attr in attrs { + if !attr.path().is_ident("sea_orm") { + continue; + } + let syn::Meta::List(meta_list) = &attr.meta else { + continue; + }; + if meta_list.tokens.to_string().contains("primary_key") { + return true; + } + } + false +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + + #[test] + fn test_extract_belongs_to_from_field_with_from() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(belongs_to, from = "user_id", to = "id")])]; + assert_eq!( + extract_belongs_to_from_field(&attrs), + Some("user_id".to_string()) + ); + } + + #[test] + fn test_extract_belongs_to_from_field_without_from() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(belongs_to, to = "id")])]; + assert_eq!(extract_belongs_to_from_field(&attrs), None); + } + + #[test] + fn test_extract_belongs_to_from_field_no_sea_orm_attr() { + let attrs: Vec = vec![syn::parse_quote!(#[serde(skip)])]; + assert_eq!(extract_belongs_to_from_field(&attrs), None); + } + + #[test] + fn test_extract_belongs_to_from_field_empty_attrs() { + assert_eq!(extract_belongs_to_from_field(&[]), None); + } + + #[test] + fn test_extract_relation_enum_with_value() { + let attrs: Vec = vec![ + syn::parse_quote!(#[sea_orm(belongs_to, relation_enum = "TargetUser", from = "target_user_id", to = "id")]), + ]; + assert_eq!( + extract_relation_enum(&attrs), + Some("TargetUser".to_string()) + ); + } + + #[test] + fn test_extract_relation_enum_without_relation_enum() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(belongs_to, from = "user_id", to = "id")])]; + assert_eq!(extract_relation_enum(&attrs), None); + } + + #[test] + fn test_extract_relation_enum_no_sea_orm_attr() { + let attrs: Vec = vec![syn::parse_quote!(#[serde(skip)])]; + assert_eq!(extract_relation_enum(&attrs), None); + } + + #[test] + fn test_extract_relation_enum_empty_attrs() { + assert_eq!(extract_relation_enum(&[]), None); + } + + #[test] + fn test_extract_via_rel_with_value() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(has_many, via_rel = "TargetUser")])]; + assert_eq!(extract_via_rel(&attrs), Some("TargetUser".to_string())); + } + + #[test] + fn test_extract_via_rel_with_relation_enum() { + let attrs: Vec = vec![ + syn::parse_quote!(#[sea_orm(has_many, relation_enum = "TargetUserNotifications", via_rel = "TargetUser")]), + ]; + assert_eq!(extract_via_rel(&attrs), Some("TargetUser".to_string())); + } + + #[test] + fn test_extract_via_rel_without_via_rel() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(has_many, relation_enum = "Memos")])]; + assert_eq!(extract_via_rel(&attrs), None); + } + + #[test] + fn test_extract_via_rel_non_sea_orm_attr() { + let attrs: Vec = vec![syn::parse_quote!(#[serde(skip)])]; + assert_eq!(extract_via_rel(&attrs), None); + } + + #[test] + fn test_extract_via_rel_empty_attrs() { + assert_eq!(extract_via_rel(&[]), None); + } + + #[test] + fn test_extract_via_rel_with_other_key_value_pairs() { + let attrs: Vec = vec![ + syn::parse_quote!(#[sea_orm(belongs_to = "super::user::Entity", from = "user_id", to = "id", via_rel = "Author")]), + ]; + assert_eq!(extract_via_rel(&attrs), Some("Author".to_string())); + } + + #[test] + fn test_extract_via_rel_multiple_sea_orm_attrs() { + let attrs: Vec = vec![ + syn::parse_quote!(#[sea_orm(has_many)]), + syn::parse_quote!(#[sea_orm(via_rel = "Comments")]), + ]; + assert_eq!(extract_via_rel(&attrs), Some("Comments".to_string())); + } + + #[test] + fn test_extract_sea_orm_default_value_float() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = 0.7)])]; + assert_eq!( + extract_sea_orm_default_value(&attrs), + Some("0.7".to_string()) + ); + } + + #[test] + fn test_extract_sea_orm_default_value_int() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = 42)])]; + assert_eq!( + extract_sea_orm_default_value(&attrs), + Some("42".to_string()) + ); + } + + #[test] + fn test_extract_sea_orm_default_value_string() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(default_value = "active")])]; + assert_eq!( + extract_sea_orm_default_value(&attrs), + Some("active".to_string()) + ); + } + + #[test] + fn test_extract_sea_orm_default_value_bool() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = true)])]; + assert_eq!( + extract_sea_orm_default_value(&attrs), + Some("true".to_string()) + ); + } + + #[test] + fn test_extract_sea_orm_default_value_with_other_attrs() { + let attrs: Vec = vec![ + syn::parse_quote!(#[sea_orm(column_type = "Decimal(Some((10, 2)))", default_value = 0.7)]), + ]; + assert_eq!( + extract_sea_orm_default_value(&attrs), + Some("0.7".to_string()) + ); + } + + #[test] + fn test_extract_sea_orm_default_value_none() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(column_type = "Text")])]; + assert_eq!(extract_sea_orm_default_value(&attrs), None); + } + + #[test] + fn test_extract_sea_orm_default_value_non_sea_orm_attr() { + let attrs: Vec = vec![syn::parse_quote!(#[serde(default)])]; + assert_eq!(extract_sea_orm_default_value(&attrs), None); + } + + #[test] + fn test_extract_sea_orm_default_value_empty_attrs() { + assert_eq!(extract_sea_orm_default_value(&[]), None); + } + + #[test] + fn test_extract_sea_orm_default_value_non_list_meta() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm])]; + assert_eq!(extract_sea_orm_default_value(&attrs), None); + } + + #[test] + fn test_extract_sea_orm_default_value_empty_value_after_equals() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = )])]; + assert_eq!(extract_sea_orm_default_value(&attrs), None); + } + + #[test] + fn test_extract_sea_orm_default_value_no_default_value_key() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(primary_key, auto_increment)])]; + assert_eq!(extract_sea_orm_default_value(&attrs), None); + } + + #[rstest] + #[case("NOW()", true)] + #[case("CURRENT_TIMESTAMP()", true)] + #[case("UUID()", true)] + #[case("gen_random_uuid()", true)] + #[case("0.7", false)] + #[case("42", false)] + #[case("true", false)] + #[case("draft", false)] + #[case("active", false)] + fn test_is_sql_function_default(#[case] value: &str, #[case] expected: bool) { + assert_eq!(is_sql_function_default(value), expected); + } + + #[test] + fn test_has_sea_orm_primary_key_true() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(primary_key)])]; + assert!(has_sea_orm_primary_key(&attrs)); + } + + #[test] + fn test_has_sea_orm_primary_key_with_other_attrs() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(primary_key, default_value = "gen_random_uuid()")])]; + assert!(has_sea_orm_primary_key(&attrs)); + } + + #[test] + fn test_has_sea_orm_primary_key_false() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + assert!(!has_sea_orm_primary_key(&attrs)); + } + + #[test] + fn test_has_sea_orm_primary_key_no_sea_orm_attr() { + let attrs: Vec = vec![syn::parse_quote!(#[serde(default)])]; + assert!(!has_sea_orm_primary_key(&attrs)); + } + + #[test] + fn test_has_sea_orm_primary_key_empty_attrs() { + assert!(!has_sea_orm_primary_key(&[])); + } + + #[test] + fn test_has_sea_orm_primary_key_non_list_meta() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm = "something"] )]; + assert!(!has_sea_orm_primary_key(&attrs)); + } +} diff --git a/crates/vespera_macro/src/schema_macro/seaorm/conversion.rs b/crates/vespera_macro/src/schema_macro/seaorm/conversion.rs new file mode 100644 index 00000000..7dd2f9b4 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/seaorm/conversion.rs @@ -0,0 +1,170 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::Type; + +use crate::schema_macro::type_utils::resolve_type_to_absolute_path; + +/// Convert `SeaORM` datetime types to chrono equivalents. +pub fn convert_seaorm_type_to_chrono(ty: &Type, source_module_path: &[String]) -> TokenStream { + let Type::Path(type_path) = ty else { + return quote! { #ty }; + }; + + let Some(segment) = type_path.path.segments.last() else { + return quote! { #ty }; + }; + + match segment.ident.to_string().as_str() { + "DateTimeWithTimeZone" => { + quote! { vespera::chrono::DateTime } + } + "DateTimeUtc" => quote! { vespera::chrono::DateTime }, + "DateTimeLocal" => quote! { vespera::chrono::DateTime }, + "FieldData" => convert_field_data(segment, source_module_path), + "NamedTempFile" => quote! { vespera::tempfile::NamedTempFile }, + _ => resolve_type_to_absolute_path(ty, source_module_path), + } +} + +fn convert_field_data(segment: &syn::PathSegment, source_module_path: &[String]) -> TokenStream { + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + let inner_args: Vec<_> = args + .args + .iter() + .map(|arg| { + if let syn::GenericArgument::Type(inner_ty) = arg { + let converted = convert_seaorm_type_to_chrono(inner_ty, source_module_path); + quote! { #converted } + } else { + quote! { #arg } + } + }) + .collect(); + quote! { vespera::multipart::FieldData<#(#inner_args),*> } + } else { + quote! { vespera::multipart::FieldData } + } +} + +/// Convert a type to chrono equivalent, handling `Option` and `Vec` wrappers. +pub fn convert_type_with_chrono(ty: &Type, source_module_path: &[String]) -> TokenStream { + if let Some((wrapper, inner_ty)) = option_or_vec_inner(ty) { + let converted_inner = convert_seaorm_type_to_chrono(inner_ty, source_module_path); + return match wrapper { + "Option" => quote! { Option<#converted_inner> }, + "Vec" => quote! { Vec<#converted_inner> }, + _ => unreachable!(), + }; + } + + convert_seaorm_type_to_chrono(ty, source_module_path) +} + +fn option_or_vec_inner(ty: &Type) -> Option<(&'static str, &Type)> { + let Type::Path(type_path) = ty else { + return None; + }; + let segment = type_path.path.segments.first()?; + let wrapper = match segment.ident.to_string().as_str() { + "Option" => "Option", + "Vec" => "Vec", + _ => return None, + }; + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments + && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() + { + Some((wrapper, inner_ty)) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + + #[rstest] + #[case::date_time_with_time_zone("seaorm_to_chrono_tz", "DateTimeWithTimeZone")] + #[case::date_time_utc("seaorm_to_chrono_utc", "DateTimeUtc")] + #[case::date_time_local("seaorm_to_chrono_local", "DateTimeLocal")] + #[case::non_path_reference("seaorm_to_chrono_ref_str", "&str")] + #[case::regular_type_passthrough("seaorm_to_chrono_string", "String")] + fn convert_seaorm_type_to_chrono_snapshot(#[case] snapshot_name: &str, #[case] input: &str) { + let ty: syn::Type = syn::parse_str(input).unwrap(); + insta::assert_snapshot!( + snapshot_name, + convert_seaorm_type_to_chrono(&ty, &[]).to_string() + ); + } + + #[rstest] + #[case::option_datetime("with_chrono_option_datetime", "Option")] + #[case::vec_datetime("with_chrono_vec_datetime", "Vec")] + #[case::plain_type_passthrough("with_chrono_plain_i32", "i32")] + fn convert_type_with_chrono_snapshot(#[case] snapshot_name: &str, #[case] input: &str) { + let ty: syn::Type = syn::parse_str(input).unwrap(); + insta::assert_snapshot!( + snapshot_name, + convert_type_with_chrono(&ty, &[]).to_string() + ); + } + + #[test] + fn test_convert_seaorm_type_to_chrono_empty_path() { + let ty = syn::Type::Path(syn::TypePath { + qself: None, + path: syn::Path { + leading_colon: None, + segments: syn::punctuated::Punctuated::new(), + }, + }); + let tokens = convert_seaorm_type_to_chrono(&ty, &[]); + assert!(tokens.to_string().is_empty() || tokens.to_string().trim().is_empty()); + } + + #[test] + fn test_convert_seaorm_type_field_data_with_generic() { + let ty: syn::Type = syn::parse_str("FieldData").unwrap(); + let output = convert_seaorm_type_to_chrono(&ty, &[]).to_string(); + assert!(output.contains("vespera :: multipart :: FieldData")); + assert!(output.contains("vespera :: tempfile :: NamedTempFile")); + } + + #[test] + fn test_convert_seaorm_type_field_data_without_generic() { + let ty: syn::Type = syn::parse_str("FieldData").unwrap(); + let output = convert_seaorm_type_to_chrono(&ty, &[]).to_string(); + assert!(output.contains("vespera :: multipart :: FieldData")); + assert!(!output.contains("NamedTempFile")); + } + + #[test] + fn test_convert_seaorm_type_field_data_with_non_type_generic() { + let ty: syn::Type = syn::parse_str("FieldData<'a>").unwrap(); + let output = convert_seaorm_type_to_chrono(&ty, &[]).to_string(); + assert!(output.contains("vespera :: multipart :: FieldData")); + } + + #[test] + fn test_convert_seaorm_type_named_temp_file() { + let ty: syn::Type = syn::parse_str("NamedTempFile").unwrap(); + let output = convert_seaorm_type_to_chrono(&ty, &[]).to_string(); + assert_eq!(output.trim(), "vespera :: tempfile :: NamedTempFile"); + } + + #[test] + fn test_convert_type_with_chrono_json_alias_uses_public_value_path() { + let ty: syn::Type = syn::parse_str("Json").unwrap(); + let tokens = convert_type_with_chrono( + &ty, + &[ + "crate".to_string(), + "models".to_string(), + "json_case".to_string(), + ], + ); + assert_eq!(tokens.to_string().trim(), "vespera :: serde_json :: Value"); + } +} diff --git a/crates/vespera_macro/src/schema_macro/seaorm/relations.rs b/crates/vespera_macro/src/schema_macro/seaorm/relations.rs new file mode 100644 index 00000000..4fce6c44 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/seaorm/relations.rs @@ -0,0 +1,475 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::Type; + +use super::attrs::{extract_belongs_to_from_field, extract_relation_enum, extract_via_rel}; +use crate::schema_macro::type_utils::is_option_type; + +/// Relation field info for generating `from_model` code. +#[derive(Clone)] +pub struct RelationFieldInfo { + pub field_name: syn::Ident, + pub relation_type: String, + pub schema_path: TokenStream, + pub is_optional: bool, + pub inline_type_info: Option<(syn::Ident, Vec)>, + pub relation_enum: Option, + pub fk_column: Option, + pub via_rel: Option, +} + +/// Check if a field in the struct is optional (`Option`). +pub fn is_field_optional_in_struct(struct_item: &syn::ItemStruct, field_name: &str) -> bool { + if let syn::Fields::Named(fields_named) = &struct_item.fields { + for field in &fields_named.named { + if let Some(ident) = &field.ident + && ident == field_name + { + return is_option_type(&field.ty); + } + } + } + false +} + +/// Convert a `SeaORM` relation type to a Schema type AND return relation info. +pub fn convert_relation_type_to_schema_with_info( + ty: &Type, + field_attrs: &[syn::Attribute], + parsed_struct: &syn::ItemStruct, + source_module_path: &[String], + field_name: syn::Ident, +) -> Option<(TokenStream, RelationFieldInfo)> { + let Type::Path(type_path) = ty else { + return None; + }; + + let segment = type_path.path.segments.last()?; + let ident_str = segment.ident.to_string(); + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() else { + return None; + }; + let Type::Path(inner_path) = inner_ty else { + return None; + }; + + let schema_path = schema_path_tokens(&inner_path.path, source_module_path); + + match ident_str.as_str() { + "HasOne" => Some(single_relation( + "HasOne", + field_name, + field_attrs, + parsed_struct, + schema_path, + )), + "HasMany" => { + let relation_enum = extract_relation_enum(field_attrs); + let via_rel = extract_via_rel(field_attrs); + let converted = quote! { Vec<#schema_path> }; + let info = RelationFieldInfo { + field_name, + relation_type: "HasMany".to_string(), + schema_path, + is_optional: false, + inline_type_info: None, + relation_enum, + fk_column: None, + via_rel, + }; + Some((converted, info)) + } + "BelongsTo" => Some(single_relation( + "BelongsTo", + field_name, + field_attrs, + parsed_struct, + schema_path, + )), + _ => None, + } +} + +fn schema_path_tokens(path: &syn::Path, source_module_path: &[String]) -> TokenStream { + let segments: Vec = path.segments.iter().map(|s| s.ident.to_string()).collect(); + let absolute_segments = absolute_schema_segments(&segments, source_module_path); + let path_idents: Vec = absolute_segments + .iter() + .map(|s| syn::Ident::new(s, proc_macro2::Span::call_site())) + .collect(); + quote! { #(#path_idents)::* } +} + +fn absolute_schema_segments(segments: &[String], source_module_path: &[String]) -> Vec { + if !segments.is_empty() && segments[0] == "super" { + let super_count = segments.iter().take_while(|s| *s == "super").count(); + let parent_path_len = source_module_path.len().saturating_sub(super_count); + let mut abs = Vec::with_capacity(parent_path_len + segments.len() - super_count); + abs.extend_from_slice(&source_module_path[..parent_path_len]); + abs.extend(segments.iter().skip(super_count).map(entity_to_schema)); + abs + } else if !segments.is_empty() && segments[0] == "crate" { + segments.iter().map(entity_to_schema).collect() + } else { + let parent_path_len = source_module_path.len().saturating_sub(1); + let mut abs = Vec::with_capacity(parent_path_len + segments.len()); + abs.extend_from_slice(&source_module_path[..parent_path_len]); + abs.extend(segments.iter().map(entity_to_schema)); + abs + } +} + +fn entity_to_schema(segment: &String) -> String { + if segment == "Entity" { + "Schema".to_string() + } else { + segment.clone() + } +} + +fn single_relation( + relation_type: &str, + field_name: syn::Ident, + field_attrs: &[syn::Attribute], + parsed_struct: &syn::ItemStruct, + schema_path: TokenStream, +) -> (TokenStream, RelationFieldInfo) { + let fk_field = extract_belongs_to_from_field(field_attrs); + let relation_enum = extract_relation_enum(field_attrs); + let is_optional = fk_field + .as_ref() + .is_none_or(|f| is_field_optional_in_struct(parsed_struct, f)); + + let converted = if is_optional { + quote! { Option> } + } else { + quote! { Box<#schema_path> } + }; + let info = RelationFieldInfo { + field_name, + relation_type: relation_type.to_string(), + schema_path, + is_optional, + inline_type_info: None, + relation_enum, + fk_column: fk_field, + via_rel: None, + }; + (converted, info) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_test_struct(def: &str) -> syn::ItemStruct { + syn::parse_str(def).unwrap() + } + + fn ident(name: &str) -> syn::Ident { + syn::Ident::new(name, proc_macro2::Span::call_site()) + } + + #[test] + fn test_is_field_optional_in_struct_optional() { + let struct_item = make_test_struct("struct Model { id: i32, user_id: Option }"); + assert!(is_field_optional_in_struct(&struct_item, "user_id")); + } + + #[test] + fn test_is_field_optional_in_struct_required() { + let struct_item = make_test_struct("struct Model { id: i32, user_id: i32 }"); + assert!(!is_field_optional_in_struct(&struct_item, "user_id")); + } + + #[test] + fn test_is_field_optional_in_struct_field_not_found() { + let struct_item = make_test_struct("struct Model { id: i32 }"); + assert!(!is_field_optional_in_struct(&struct_item, "nonexistent")); + } + + #[test] + fn test_is_field_optional_in_struct_tuple_struct() { + let struct_item: syn::ItemStruct = + syn::parse_str("struct TupleStruct(i32, Option);").unwrap(); + assert!(!is_field_optional_in_struct(&struct_item, "0")); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_non_path_type() { + let ty: syn::Type = syn::parse_str("&str").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32 }"); + assert!( + convert_relation_type_to_schema_with_info(&ty, &[], &struct_item, &[], ident("user")) + .is_none() + ); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_empty_segments() { + let ty = syn::Type::Path(syn::TypePath { + qself: None, + path: syn::Path { + leading_colon: None, + segments: syn::punctuated::Punctuated::new(), + }, + }); + let struct_item = make_test_struct("struct Model { id: i32 }"); + assert!( + convert_relation_type_to_schema_with_info(&ty, &[], &struct_item, &[], ident("user")) + .is_none() + ); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_no_angle_brackets() { + let ty: syn::Type = syn::parse_str("HasOne").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32 }"); + assert!( + convert_relation_type_to_schema_with_info(&ty, &[], &struct_item, &[], ident("user")) + .is_none() + ); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_non_type_generic() { + let ty: syn::Type = syn::parse_str("HasOne<'a>").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32 }"); + assert!( + convert_relation_type_to_schema_with_info(&ty, &[], &struct_item, &[], ident("user")) + .is_none() + ); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_non_path_inner() { + let ty: syn::Type = syn::parse_str("HasOne<&str>").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32 }"); + assert!( + convert_relation_type_to_schema_with_info(&ty, &[], &struct_item, &[], ident("user")) + .is_none() + ); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_has_one_optional() { + let ty: syn::Type = syn::parse_str("HasOne").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32, user_id: Option }"); + let attrs = vec![syn::parse_quote!(#[sea_orm(belongs_to, from = "user_id")])]; + let module_path = vec![ + "crate".to_string(), + "models".to_string(), + "memo".to_string(), + ]; + let (tokens, info) = convert_relation_type_to_schema_with_info( + &ty, + &attrs, + &struct_item, + &module_path, + ident("user"), + ) + .unwrap(); + assert_eq!(info.relation_type, "HasOne"); + assert!(info.is_optional); + assert!(tokens.to_string().contains("Option")); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_has_one_required() { + let ty: syn::Type = syn::parse_str("HasOne").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32, user_id: i32 }"); + let attrs = vec![syn::parse_quote!(#[sea_orm(belongs_to, from = "user_id")])]; + let module_path = vec![ + "crate".to_string(), + "models".to_string(), + "memo".to_string(), + ]; + let (tokens, info) = convert_relation_type_to_schema_with_info( + &ty, + &attrs, + &struct_item, + &module_path, + ident("user"), + ) + .unwrap(); + assert_eq!(info.relation_type, "HasOne"); + assert!(!info.is_optional); + assert!(tokens.to_string().contains("Box")); + assert!(!tokens.to_string().contains("Option")); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_has_one_no_fk() { + let ty: syn::Type = syn::parse_str("HasOne").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32 }"); + let module_path = vec![ + "crate".to_string(), + "models".to_string(), + "memo".to_string(), + ]; + let (tokens, info) = convert_relation_type_to_schema_with_info( + &ty, + &[], + &struct_item, + &module_path, + ident("user"), + ) + .unwrap(); + assert!(info.is_optional); + assert!(tokens.to_string().contains("Option")); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_has_many() { + let ty: syn::Type = syn::parse_str("HasMany").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32 }"); + let module_path = vec![ + "crate".to_string(), + "models".to_string(), + "user".to_string(), + ]; + let (tokens, info) = convert_relation_type_to_schema_with_info( + &ty, + &[], + &struct_item, + &module_path, + ident("memos"), + ) + .unwrap(); + assert_eq!(info.relation_type, "HasMany"); + assert!(!info.is_optional); + assert!(tokens.to_string().contains("Vec")); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_belongs_to_optional() { + let ty: syn::Type = syn::parse_str("BelongsTo").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32, user_id: Option }"); + let attrs = vec![syn::parse_quote!(#[sea_orm(belongs_to, from = "user_id")])]; + let module_path = vec![ + "crate".to_string(), + "models".to_string(), + "memo".to_string(), + ]; + let (tokens, info) = convert_relation_type_to_schema_with_info( + &ty, + &attrs, + &struct_item, + &module_path, + ident("user"), + ) + .unwrap(); + assert_eq!(info.relation_type, "BelongsTo"); + assert!(info.is_optional); + assert!(tokens.to_string().contains("Option")); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_belongs_to_required() { + let ty: syn::Type = syn::parse_str("BelongsTo").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32, user_id: i32 }"); + let attrs = vec![syn::parse_quote!(#[sea_orm(belongs_to, from = "user_id")])]; + let module_path = vec![ + "crate".to_string(), + "models".to_string(), + "memo".to_string(), + ]; + let (tokens, info) = convert_relation_type_to_schema_with_info( + &ty, + &attrs, + &struct_item, + &module_path, + ident("user"), + ) + .unwrap(); + assert_eq!(info.relation_type, "BelongsTo"); + assert!(!info.is_optional); + assert!(!tokens.to_string().contains("Option")); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_unknown_relation() { + let ty: syn::Type = syn::parse_str("SomeOtherType").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32 }"); + assert!( + convert_relation_type_to_schema_with_info(&ty, &[], &struct_item, &[], ident("user")) + .is_none() + ); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_super_path() { + let ty: syn::Type = syn::parse_str("HasMany").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32 }"); + let module_path = vec![ + "crate".to_string(), + "models".to_string(), + "user".to_string(), + ]; + let (tokens, _) = convert_relation_type_to_schema_with_info( + &ty, + &[], + &struct_item, + &module_path, + ident("memos"), + ) + .unwrap(); + let output = tokens.to_string(); + assert!(output.contains("crate")); + assert!(output.contains("models")); + assert!(output.contains("memo")); + assert!(output.contains("Schema")); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_crate_path() { + let ty: syn::Type = syn::parse_str("HasMany").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32 }"); + let module_path = vec![ + "crate".to_string(), + "models".to_string(), + "user".to_string(), + ]; + let (tokens, _) = convert_relation_type_to_schema_with_info( + &ty, + &[], + &struct_item, + &module_path, + ident("memos"), + ) + .unwrap(); + let output = tokens.to_string(); + assert!(output.contains("crate")); + assert!(output.contains("models")); + assert!(output.contains("memo")); + assert!(output.contains("Schema")); + assert!(!output.contains("Entity")); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_relative_path() { + let ty: syn::Type = syn::parse_str("HasOne").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32 }"); + let module_path = vec![ + "crate".to_string(), + "models".to_string(), + "memo".to_string(), + ]; + let (tokens, _) = convert_relation_type_to_schema_with_info( + &ty, + &[], + &struct_item, + &module_path, + ident("user"), + ) + .unwrap(); + let output = tokens.to_string(); + assert!(output.contains("crate")); + assert!(output.contains("models")); + assert!(output.contains("user")); + assert!(output.contains("Schema")); + } +} diff --git a/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_local.snap b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_local.snap new file mode 100644 index 00000000..dd938041 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_local.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/seaorm/tests.rs +expression: "convert_seaorm_type_to_chrono(&ty, &[]).to_string()" +--- +vespera :: chrono :: DateTime < vespera :: chrono :: Local > diff --git a/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_ref_str.snap b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_ref_str.snap new file mode 100644 index 00000000..0460e4fb --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_ref_str.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/seaorm/tests.rs +expression: "convert_seaorm_type_to_chrono(&ty, &[]).to_string()" +--- +& str diff --git a/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_string.snap b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_string.snap new file mode 100644 index 00000000..24e7c352 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_string.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/seaorm/tests.rs +expression: "convert_seaorm_type_to_chrono(&ty, &[]).to_string()" +--- +String diff --git a/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_tz.snap b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_tz.snap new file mode 100644 index 00000000..ee7a1782 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_tz.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/seaorm/tests.rs +expression: "convert_seaorm_type_to_chrono(&ty, &[]).to_string()" +--- +vespera :: chrono :: DateTime < vespera :: chrono :: FixedOffset > diff --git a/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_utc.snap b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_utc.snap new file mode 100644 index 00000000..929b264c --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_utc.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/seaorm/tests.rs +expression: "convert_seaorm_type_to_chrono(&ty, &[]).to_string()" +--- +vespera :: chrono :: DateTime < vespera :: chrono :: Utc > diff --git a/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__with_chrono_option_datetime.snap b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__with_chrono_option_datetime.snap new file mode 100644 index 00000000..12924ed7 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__with_chrono_option_datetime.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/seaorm/tests.rs +expression: "convert_type_with_chrono(&ty, &[]).to_string()" +--- +Option < vespera :: chrono :: DateTime < vespera :: chrono :: FixedOffset > > diff --git a/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__with_chrono_plain_i32.snap b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__with_chrono_plain_i32.snap new file mode 100644 index 00000000..faa57f11 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__with_chrono_plain_i32.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/seaorm/tests.rs +expression: "convert_type_with_chrono(&ty, &[]).to_string()" +--- +i32 diff --git a/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__with_chrono_vec_datetime.snap b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__with_chrono_vec_datetime.snap new file mode 100644 index 00000000..0a258b67 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__with_chrono_vec_datetime.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/seaorm/tests.rs +expression: "convert_type_with_chrono(&ty, &[]).to_string()" +--- +Vec < vespera :: chrono :: DateTime < vespera :: chrono :: FixedOffset > > diff --git a/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__from_model__tests__entity_path_crate_qualified.snap b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__from_model__tests__entity_path_crate_qualified.snap new file mode 100644 index 00000000..d37ce27f --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__from_model__tests__entity_path_crate_qualified.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/tests.rs +expression: "build_entity_path_from_schema_path(&schema_path, &[]).to_string()" +--- +crate :: models :: user :: Entity diff --git a/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__from_model__tests__entity_path_deeply_nested.snap b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__from_model__tests__entity_path_deeply_nested.snap new file mode 100644 index 00000000..02f179a4 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__from_model__tests__entity_path_deeply_nested.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/tests.rs +expression: "build_entity_path_from_schema_path(&schema_path, &[]).to_string()" +--- +crate :: api :: models :: entities :: user :: Entity diff --git a/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__from_model__tests__entity_path_simple_module.snap b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__from_model__tests__entity_path_simple_module.snap new file mode 100644 index 00000000..0308b334 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__from_model__tests__entity_path_simple_module.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/tests.rs +expression: "build_entity_path_from_schema_path(&schema_path, &[]).to_string()" +--- +user :: Entity diff --git a/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__from_model__tests__entity_path_single_segment.snap b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__from_model__tests__entity_path_single_segment.snap new file mode 100644 index 00000000..8cb5bbc6 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__from_model__tests__entity_path_single_segment.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/tests.rs +expression: "build_entity_path_from_schema_path(&schema_path, &[]).to_string()" +--- +Entity diff --git a/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__inline_types__tests__complex_field_types.snap b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__inline_types__tests__complex_field_types.snap new file mode 100644 index 00000000..b4afc195 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__inline_types__tests__complex_field_types.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespera_macro/src/schema_macro/inline_types/tests.rs +expression: pretty(&generate_inline_type_definition(&inline_type)) +--- +#[derive(Clone, serde::Serialize, serde::Deserialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +pub struct ComplexType { + pub id: i32, + pub tags: Vec, + pub metadata: Option>, +} diff --git a/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__inline_types__tests__doc_attribute.snap b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__inline_types__tests__doc_attribute.snap new file mode 100644 index 00000000..e1bd12fe --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__inline_types__tests__doc_attribute.snap @@ -0,0 +1,10 @@ +--- +source: crates/vespera_macro/src/schema_macro/inline_types/tests.rs +expression: pretty(&generate_inline_type_definition(&inline_type)) +--- +#[derive(Clone, serde::Serialize, serde::Deserialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +pub struct DocType { + ///This is a documented field + pub documented_field: String, +} diff --git a/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__inline_types__tests__empty_fields.snap b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__inline_types__tests__empty_fields.snap new file mode 100644 index 00000000..ac96ae4c --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__inline_types__tests__empty_fields.snap @@ -0,0 +1,7 @@ +--- +source: crates/vespera_macro/src/schema_macro/inline_types/tests.rs +expression: pretty(&generate_inline_type_definition(&inline_type)) +--- +#[derive(Clone, serde::Serialize, serde::Deserialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +pub struct EmptyType {} diff --git a/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__inline_types__tests__field_attr_rename_snake_case.snap b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__inline_types__tests__field_attr_rename_snake_case.snap new file mode 100644 index 00000000..56cdcd6e --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__inline_types__tests__field_attr_rename_snake_case.snap @@ -0,0 +1,10 @@ +--- +source: crates/vespera_macro/src/schema_macro/inline_types/tests.rs +expression: pretty(&generate_inline_type_definition(&inline_type)) +--- +#[derive(Clone, serde::Serialize, serde::Deserialize, vespera::Schema)] +#[serde(rename_all = "snake_case")] +pub struct TestType { + #[serde(rename = "renamed")] + pub field: String, +} diff --git a/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__inline_types__tests__from_def_created_at_type.snap b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__inline_types__tests__from_def_created_at_type.snap new file mode 100644 index 00000000..50eaff15 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__inline_types__tests__from_def_created_at_type.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/inline_types/tests.rs +expression: created_at.ty.to_string() +--- +vespera :: chrono :: DateTime < vespera :: chrono :: FixedOffset > diff --git a/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__inline_types__tests__multiple_field_attrs_pascal_case.snap b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__inline_types__tests__multiple_field_attrs_pascal_case.snap new file mode 100644 index 00000000..b24b0142 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__inline_types__tests__multiple_field_attrs_pascal_case.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespera_macro/src/schema_macro/inline_types/tests.rs +expression: pretty(&generate_inline_type_definition(&inline_type)) +--- +#[derive(Clone, serde::Serialize, serde::Deserialize, vespera::Schema)] +#[serde(rename_all = "PascalCase")] +pub struct MultiAttrType { + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub field: String, +} diff --git a/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__inline_types__tests__no_relations_datetime_types.snap b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__inline_types__tests__no_relations_datetime_types.snap new file mode 100644 index 00000000..01f0c548 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__inline_types__tests__no_relations_datetime_types.snap @@ -0,0 +1,6 @@ +--- +source: crates/vespera_macro/src/schema_macro/inline_types/tests.rs +expression: "format!(\"created_at: {}\\nupdated_at: {}\", ty_of(\"created_at\"),\nty_of(\"updated_at\"),)" +--- +created_at: vespera :: chrono :: DateTime < vespera :: chrono :: FixedOffset > +updated_at: Option < vespera :: chrono :: DateTime < vespera :: chrono :: FixedOffset > > diff --git a/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__inline_types__tests__two_plain_fields_camel_case.snap b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__inline_types__tests__two_plain_fields_camel_case.snap new file mode 100644 index 00000000..6c3ef5c5 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__inline_types__tests__two_plain_fields_camel_case.snap @@ -0,0 +1,10 @@ +--- +source: crates/vespera_macro/src/schema_macro/inline_types/tests.rs +expression: pretty(&generate_inline_type_definition(&inline_type)) +--- +#[derive(Clone, serde::Serialize, serde::Deserialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +pub struct UserInline { + pub id: i32, + pub name: String, +} diff --git a/crates/vespera_macro/src/schema_macro/tests.rs b/crates/vespera_macro/src/schema_macro/tests.rs deleted file mode 100644 index 3368fecf..00000000 --- a/crates/vespera_macro/src/schema_macro/tests.rs +++ /dev/null @@ -1,2392 +0,0 @@ -//! Tests for schema_macro module -//! -//! This file contains all unit tests for the schema generation functionality. - -use std::collections::HashMap; - -use serial_test::serial; - -use super::*; - -fn create_test_struct_metadata(name: &str, definition: &str) -> StructMetadata { - StructMetadata::new(name.to_string(), definition.to_string()) -} - -fn to_storage(items: Vec) -> HashMap { - items.into_iter().map(|s| (s.name.clone(), s)).collect() -} - -#[test] -fn test_generate_schema_code_simple_struct() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(User); - let input: SchemaInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_code(&input, &storage); - - assert!(result.is_ok()); - let output = result.unwrap().to_string(); - assert!(output.contains("properties")); - assert!(output.contains("Schema")); -} - -#[test] -fn test_generate_schema_code_with_omit() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String, pub password: String }", - )]); - - let tokens = quote!(User, omit = ["password"]); - let input: SchemaInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_code(&input, &storage); - - assert!(result.is_ok()); - let output = result.unwrap().to_string(); - assert!(output.contains("properties")); -} - -#[test] -fn test_generate_schema_code_with_pick() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String, pub email: String }", - )]); - - let tokens = quote!(User, pick = ["id", "name"]); - let input: SchemaInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_code(&input, &storage); - - assert!(result.is_ok()); - let output = result.unwrap().to_string(); - assert!(output.contains("properties")); -} - -#[test] -fn test_generate_schema_code_type_not_found() { - let storage: HashMap = HashMap::new(); - - let tokens = quote!(NonExistent); - let input: SchemaInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_code(&input, &storage); - - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("not found")); -} - -#[test] -fn test_generate_schema_code_malformed_definition() { - let storage = to_storage(vec![create_test_struct_metadata( - "BadStruct", - "this is not valid rust code {{{", - )]); - - let tokens = quote!(BadStruct); - let input: SchemaInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_code(&input, &storage); - - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("failed to parse")); -} - -#[test] -fn test_generate_schema_type_code_pick_nonexistent_field() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(NewUser from User, pick = ["nonexistent"]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("does not exist")); - assert!(err.contains("nonexistent")); -} - -#[test] -fn test_generate_schema_type_code_omit_nonexistent_field() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(NewUser from User, omit = ["nonexistent"]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("does not exist")); - assert!(err.contains("nonexistent")); -} - -#[test] -fn test_generate_schema_type_code_rename_nonexistent_field() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(NewUser from User, rename = [("nonexistent", "new_name")]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("does not exist")); - assert!(err.contains("nonexistent")); -} - -#[test] -fn test_generate_schema_type_code_type_not_found() { - let storage: HashMap = HashMap::new(); - - let tokens = quote!(NewUser from NonExistent); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("not found")); -} - -#[test] -fn test_generate_schema_type_code_success() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(CreateUser from User, pick = ["name"]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("CreateUser")); - assert!(output.contains("name")); -} - -#[test] -fn test_generate_schema_type_code_with_omit() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String, pub password: String }", - )]); - - let tokens = quote!(SafeUser from User, omit = ["password"]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("SafeUser")); - assert!(!output.contains("password")); -} - -#[test] -fn test_generate_schema_type_code_with_add() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(UserWithExtra from User, add = [("extra": String)]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("UserWithExtra")); - assert!(output.contains("extra")); -} - -#[test] -fn test_generate_schema_type_code_relation_fields_can_be_omitted_and_readded_with_custom_types() { - let storage = to_storage(vec![create_test_struct_metadata( - "Model", - r#"#[sea_orm(table_name = "article")] - pub struct Model { - pub id: i64, - pub title: String, - pub user: HasOne, - pub category: HasOne, - pub article_review_users: HasMany - }"#, - )]); - - let tokens = quote!( - ArticleResponse from Model, - omit = ["user", "category", "article_review_users"], - add = [ - ("user": Option), - ("category": Option), - ("article_review_users": Vec) - ] - ); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("pub user : Option < UserInArticle >")); - assert!(output.contains("pub category : Option < CategoryInArticle >")); - assert!(output.contains("pub article_review_users : Vec < ArticleReviewUserInArticle >")); - assert!(!output.contains("Box < Schema >")); - assert!(!output.contains("impl From")); -} - -#[test] -fn test_generate_schema_type_code_same_file_relation_adapters_for_add_mode() { - let storage = to_storage(vec![ - create_test_struct_metadata( - "Model", - r#"#[sea_orm(table_name = "article")] - pub struct Model { - pub id: i64, - pub title: String, - pub user: HasOne, - pub category: HasOne, - pub article_review_users: HasMany - }"#, - ), - create_test_struct_metadata( - "UserInArticle", - "struct UserInArticle { id: i32, name: String }", - ), - create_test_struct_metadata( - "CategoryInArticle", - "struct CategoryInArticle { id: i64, name: String }", - ), - ]); - - let tokens = quote!( - ArticleResponse from Model, - add = [("article_review_users": Vec)] - ); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("pub user : __VesperaArticleResponseUserRelation")); - assert!(output.contains("pub category : __VesperaArticleResponseCategoryRelation")); - assert!(output.contains("impl From < Option <")); - assert!(output.contains("for __VesperaArticleResponseUserRelation")); - assert!(output.contains("for __VesperaArticleResponseCategoryRelation")); - assert!(output.contains("impl Clone for UserInArticle")); - assert!(output.contains("impl Clone for CategoryInArticle")); -} - -#[test] -fn test_maybe_generate_same_file_relation_override_skips_redundant_clone_and_deserialize_impls() { - // Same-file relation override DTOs that ALREADY carry `Clone` and - // `Deserialize` derives must NOT have the macro re-emit those - // impls — otherwise the generated code would conflict with the - // user-provided derive. Hits the "DTO already has derive" empty- - // quote branches inside `maybe_generate_same_file_relation_override`. - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), - relation_type: "HasOne".to_string(), - schema_path: quote!(crate::models::user::Schema), - is_optional: true, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - // Bare `Clone` and `Deserialize` idents — has_derive matches the - // single-segment path, hitting the empty-quote branches at lines - // 208 (clone_impl) and 222 (deserialize_impl). - let storage = to_storage(vec![create_test_struct_metadata( - "UserInArticle", - r"#[derive(Clone, Deserialize)] - struct UserInArticle { id: i32, name: String }", - )]); - let new_type_name = syn::Ident::new("ArticleResponse", proc_macro2::Span::call_site()); - - let (override_field_ty, helper_tokens) = - maybe_generate_same_file_relation_override(&new_type_name, "user", &rel_info, &storage) - .expect("override generation should succeed") - .expect("DTO is present in storage → override should be generated"); - - let output = helper_tokens.to_string(); - let field_ty = override_field_ty.to_string(); - assert!( - field_ty.contains("__VesperaArticleResponseUserRelation"), - "expected override field type to reference relation adapter, got: {field_ty}" - ); - // No `impl Clone for UserInArticle` — DTO already derives Clone. - assert!( - !output.contains("impl Clone for UserInArticle"), - "macro should skip Clone impl when DTO already derives Clone, got: {output}" - ); - // No proxy `Deserialize` derive struct — DTO already derives Deserialize. - assert!( - !output.contains("__VesperaArticleResponseUserProxy"), - "macro should skip Deserialize proxy when DTO already derives Deserialize, got: {output}" - ); - // Relation wrapper struct still emitted regardless of derives. - assert!( - output.contains("__VesperaArticleResponseUserRelation"), - "relation wrapper missing: {output}" - ); -} - -#[test] -fn test_generate_schema_type_code_generates_from_impl() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(UserResponse from User, pick = ["id", "name"]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("impl From")); - assert!(output.contains("for UserResponse")); -} - -#[test] -fn test_generate_schema_type_code_no_from_impl_with_add() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(UserWithExtra from User, add = [("extra": String)]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!( - output.contains("UserWithExtra"), - "expected struct UserWithExtra in output: {output}" - ); - assert!( - !output.contains("impl From"), - "expected no From impl when `add` is used: {output}" - ); -} - -// ======================== -// is_parseable_type tests -// ======================== - -#[test] -fn test_is_parseable_type_primitives() { - for ty_str in &[ - "i8", "i16", "i32", "i64", "i128", "isize", "u8", "u16", "u32", "u64", "u128", "usize", - "f32", "f64", "bool", "String", "Decimal", - ] { - let ty: syn::Type = syn::parse_str(ty_str).unwrap(); - assert!(is_parseable_type(&ty), "{ty_str} should be parseable"); - } -} - -#[test] -fn test_is_parseable_type_non_parseable() { - let ty: syn::Type = syn::parse_str("MyEnum").unwrap(); - assert!(!is_parseable_type(&ty)); -} - -#[test] -fn test_is_parseable_type_non_path() { - let ty: syn::Type = syn::parse_str("&str").unwrap(); - assert!(!is_parseable_type(&ty)); -} - -// ====================================== -// generate_sea_orm_default_attrs tests -// ====================================== - -#[test] -fn test_sea_orm_default_attrs_optional_field_skips() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "42")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("i32").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = - generate_sea_orm_default_attrs(&attrs, &struct_name, "count", &ty, &ty, true, &mut fns); - assert!(serde.is_empty()); - assert!(schema.is_empty()); - assert!(fns.is_empty()); -} - -#[test] -fn test_sea_orm_default_attrs_no_default_and_no_pk() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(unique)])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("String").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = - generate_sea_orm_default_attrs(&attrs, &struct_name, "email", &ty, &ty, false, &mut fns); - assert!(serde.is_empty()); - assert!(schema.is_empty()); - assert!(fns.is_empty()); -} - -#[test] -fn test_sea_orm_default_attrs_primary_key_generates_defaults() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(primary_key)])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("i32").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = - generate_sea_orm_default_attrs(&attrs, &struct_name, "id", &ty, &ty, false, &mut fns); - let serde_str = serde.to_string(); - assert!( - serde_str.contains("serde"), - "primary_key should generate serde default: {serde_str}" - ); - let schema_str = schema.to_string(); - assert!( - schema_str.contains('0'), - "primary_key i32 should have schema default 0: {schema_str}" - ); - assert_eq!(fns.len(), 1, "should generate a default function"); -} - -#[test] -fn test_sea_orm_default_attrs_sql_function_generates_defaults() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("DateTimeWithTimeZone").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = generate_sea_orm_default_attrs( - &attrs, - &struct_name, - "created_at", - &ty, - &ty, - false, - &mut fns, - ); - let serde_str = serde.to_string(); - assert!( - serde_str.contains("serde"), - "SQL function default should generate serde default: {serde_str}" - ); - let schema_str = schema.to_string(); - assert!( - schema_str.contains("1970-01-01"), - "DateTimeWithTimeZone should have epoch default: {schema_str}" - ); - assert_eq!(fns.len(), 1, "should generate a default function"); -} - -#[test] -fn test_sea_orm_default_attrs_sql_function_uuid() { - let attrs: Vec = - vec![syn::parse_quote!(#[sea_orm(primary_key, default_value = "gen_random_uuid()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("Uuid").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = - generate_sea_orm_default_attrs(&attrs, &struct_name, "id", &ty, &ty, false, &mut fns); - let serde_str = serde.to_string(); - assert!( - serde_str.contains("serde"), - "UUID SQL default should generate serde default: {serde_str}" - ); - let schema_str = schema.to_string(); - assert!( - schema_str.contains("00000000-0000-0000-0000-000000000000"), - "Uuid should have nil UUID default: {schema_str}" - ); - assert_eq!(fns.len(), 1); -} - -#[test] -fn test_sea_orm_default_attrs_sql_function_unknown_type_skips() { - let attrs: Vec = - vec![syn::parse_quote!(#[sea_orm(default_value = "SOME_FUNC()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("MyCustomType").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = - generate_sea_orm_default_attrs(&attrs, &struct_name, "field", &ty, &ty, false, &mut fns); - assert!(serde.is_empty(), "unknown type should skip serde default"); - assert!(schema.is_empty(), "unknown type should skip schema default"); - assert!(fns.is_empty()); -} - -#[test] -fn test_sea_orm_default_attrs_existing_serde_default() { - let attrs: Vec = vec![ - syn::parse_quote!(#[sea_orm(default_value = "42")]), - syn::parse_quote!(#[serde(default)]), - ]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("i32").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = - generate_sea_orm_default_attrs(&attrs, &struct_name, "count", &ty, &ty, false, &mut fns); - // serde attr should be empty (already has serde default) - assert!(serde.is_empty()); - // schema attr should still be generated - let schema_str = schema.to_string(); - assert!( - schema_str.contains("schema"), - "should have schema attr: {schema_str}" - ); - assert!( - fns.is_empty(), - "no default fn needed when serde(default) exists" - ); -} - -#[test] -fn test_sea_orm_default_attrs_non_parseable_type() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "Active")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("MyEnum").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = - generate_sea_orm_default_attrs(&attrs, &struct_name, "status", &ty, &ty, false, &mut fns); - // serde attr empty (non-parseable type) - assert!(serde.is_empty()); - // schema attr still generated - let schema_str = schema.to_string(); - assert!( - schema_str.contains("schema"), - "should have schema attr: {schema_str}" - ); - assert!(fns.is_empty()); -} - -#[test] -fn test_sea_orm_default_attrs_full_generation() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "42")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("i32").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = - generate_sea_orm_default_attrs(&attrs, &struct_name, "count", &ty, &ty, false, &mut fns); - // Both serde and schema attrs should be generated - let serde_str = serde.to_string(); - assert!( - serde_str.contains("serde"), - "should have serde attr: {serde_str}" - ); - assert!( - serde_str.contains("default_Test_count"), - "should reference generated fn: {serde_str}" - ); - let schema_str = schema.to_string(); - assert!( - schema_str.contains("schema"), - "should have schema attr: {schema_str}" - ); - // Default function should be generated - assert_eq!(fns.len(), 1, "should generate one default function"); - let fn_str = fns[0].to_string(); - assert!( - fn_str.contains("default_Test_count"), - "fn name should match: {fn_str}" - ); -} - -#[test] -fn test_generate_schema_type_code_with_partial_all() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String, pub bio: Option }", - )]); - - let tokens = quote!(UpdateUser from User, partial); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("Option < i32 >")); - assert!(output.contains("Option < String >")); -} - -#[test] -fn test_generate_schema_type_code_with_partial_fields() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String, pub email: String }", - )]); - - let tokens = quote!(UpdateUser from User, partial = ["name"]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!( - output.contains("UpdateUser"), - "should contain generated struct name: {output}" - ); -} - -// ============================================================ -// Coverage: omit_default in generate_schema_type_code (line 180) -// ============================================================ - -#[test] -fn test_generate_schema_type_code_with_omit_default() { - let storage = to_storage(vec![create_test_struct_metadata( - "Model", - r#"#[sea_orm(table_name = "items")] - pub struct Model { - #[sea_orm(primary_key)] - pub id: i32, - pub name: String, - #[sea_orm(default_value = "NOW()")] - pub created_at: DateTimeWithTimeZone, - }"#, - )]); - - let tokens = quote!(CreateItemRequest from Model, omit_default); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // id (primary_key) and created_at (default_value) should be omitted - assert!( - !output.contains("id :"), - "id should be omitted by omit_default: {output}" - ); - assert!( - !output.contains("created_at"), - "created_at should be omitted by omit_default: {output}" - ); - // name should remain - assert!(output.contains("name"), "name should remain: {output}"); -} - -// ============================================================ -// Coverage: SQL function default with existing serde default (line 554) -// ============================================================ - -#[test] -fn test_sea_orm_default_attrs_sql_function_with_existing_serde_default() { - let attrs: Vec = vec![ - syn::parse_quote!(#[sea_orm(default_value = "NOW()")]), - syn::parse_quote!(#[serde(default)]), - ]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("DateTimeWithTimeZone").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = generate_sea_orm_default_attrs( - &attrs, - &struct_name, - "created_at", - &ty, - &ty, - false, - &mut fns, - ); - // serde attr should be empty (already has serde default) - assert!(serde.is_empty()); - // schema attr should still be generated - let schema_str = schema.to_string(); - assert!( - schema_str.contains("schema"), - "should have schema attr: {schema_str}" - ); - assert!( - schema_str.contains("1970-01-01"), - "should have epoch default: {schema_str}" - ); - assert!( - fns.is_empty(), - "no default fn needed when serde(default) exists" - ); -} - -// ============================================================ -// Coverage: sql_function_default_for_type branches (lines 580-615) -// ============================================================ - -#[test] -fn test_sea_orm_default_attrs_sql_function_non_path_type() { - // Non-Path type (reference) triggers early return None in sql_function_default_for_type - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("&str").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = - generate_sea_orm_default_attrs(&attrs, &struct_name, "field", &ty, &ty, false, &mut fns); - assert!(serde.is_empty(), "non-Path type should skip serde default"); - assert!( - schema.is_empty(), - "non-Path type should skip schema default" - ); - assert!(fns.is_empty()); -} - -#[test] -fn test_sea_orm_default_attrs_sql_function_datetime() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("DateTime").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = generate_sea_orm_default_attrs( - &attrs, - &struct_name, - "created_at", - &ty, - &ty, - false, - &mut fns, - ); - let serde_str = serde.to_string(); - assert!( - serde_str.contains("serde"), - "DateTime should generate serde default: {serde_str}" - ); - let schema_str = schema.to_string(); - assert!( - schema_str.contains("1970-01-01T00:00:00+00:00"), - "DateTime should have epoch default: {schema_str}" - ); - assert_eq!(fns.len(), 1); -} - -#[test] -fn test_sea_orm_default_attrs_sql_function_naive_datetime() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("NaiveDateTime").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = generate_sea_orm_default_attrs( - &attrs, - &struct_name, - "created_at", - &ty, - &ty, - false, - &mut fns, - ); - let serde_str = serde.to_string(); - assert!( - serde_str.contains("serde"), - "NaiveDateTime should generate serde default: {serde_str}" - ); - let schema_str = schema.to_string(); - assert!( - schema_str.contains("1970-01-01T00:00:00"), - "NaiveDateTime should have epoch default: {schema_str}" - ); - assert_eq!(fns.len(), 1); -} - -#[test] -fn test_sea_orm_default_attrs_sql_function_naive_date() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("NaiveDate").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = generate_sea_orm_default_attrs( - &attrs, - &struct_name, - "date_field", - &ty, - &ty, - false, - &mut fns, - ); - let serde_str = serde.to_string(); - assert!( - serde_str.contains("serde"), - "NaiveDate should generate serde default: {serde_str}" - ); - let schema_str = schema.to_string(); - assert!( - schema_str.contains("1970-01-01"), - "NaiveDate should have date default: {schema_str}" - ); - assert_eq!(fns.len(), 1); -} - -#[test] -fn test_sea_orm_default_attrs_sql_function_naive_time() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("NaiveTime").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = generate_sea_orm_default_attrs( - &attrs, - &struct_name, - "time_field", - &ty, - &ty, - false, - &mut fns, - ); - let serde_str = serde.to_string(); - assert!( - serde_str.contains("serde"), - "NaiveTime should generate serde default: {serde_str}" - ); - let schema_str = schema.to_string(); - assert!( - schema_str.contains("00:00:00"), - "NaiveTime should have time default: {schema_str}" - ); - assert_eq!(fns.len(), 1); -} - -#[test] -fn test_sea_orm_default_attrs_sql_function_time_type() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("Time").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = generate_sea_orm_default_attrs( - &attrs, - &struct_name, - "time_field", - &ty, - &ty, - false, - &mut fns, - ); - let serde_str = serde.to_string(); - assert!( - serde_str.contains("serde"), - "Time should generate serde default: {serde_str}" - ); - let schema_str = schema.to_string(); - assert!( - schema_str.contains("00:00:00"), - "Time should have time default: {schema_str}" - ); - assert_eq!(fns.len(), 1); -} - -// --- Coverage: is_parseable_type empty segments --- - -#[test] -fn test_is_parseable_type_empty_segments() { - // Synthetically construct a Type::Path with empty segments (impossible through parsing) - let ty = syn::Type::Path(syn::TypePath { - qself: None, - path: syn::Path { - leading_colon: None, - segments: syn::punctuated::Punctuated::new(), - }, - }); - assert!(!is_parseable_type(&ty)); -} - -#[test] -fn test_generate_schema_type_code_partial_nonexistent_field() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(UpdateUser from User, partial = ["nonexistent"]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("does not exist")); - assert!(err.contains("nonexistent")); -} - -#[test] -fn test_generate_schema_type_code_partial_from_impl_wraps_some() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(UpdateUser from User, partial); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("Some (source . id)")); - assert!(output.contains("Some (source . name)")); -} - -#[test] -fn test_generate_schema_type_code_preserves_struct_doc() { - let input = SchemaTypeInput { - new_type: syn::Ident::new("NewUser", proc_macro2::Span::call_site()), - source_type: syn::parse_str("User").unwrap(), - omit: None, - pick: None, - rename: None, - add: None, - derive_clone: true, - partial: None, - schema_name: None, - ignore_schema: false, - rename_all: None, - multipart: false, - omit_default: false, - }; - let struct_def = StructMetadata { - name: "User".to_string(), - definition: r" - /// User struct documentation - pub struct User { - /// The user ID - pub id: i32, - /// The user name - pub name: String, - } - " - .to_string(), - include_in_openapi: true, - field_defaults: std::collections::BTreeMap::new(), - }; - let storage = to_storage(vec![struct_def]); - let result = generate_schema_type_code(&input, &storage); - assert!(result.is_ok()); - let (tokens, _) = result.unwrap(); - let tokens_str = tokens.to_string(); - assert!(tokens_str.contains("User struct documentation") || tokens_str.contains("doc")); -} - -// Tests for serde attribute filtering from source struct - -#[test] -fn test_generate_schema_type_code_inherits_source_rename_all() { - // Source struct has serde(rename_all = "snake_case") - let storage = to_storage(vec![create_test_struct_metadata( - "User", - r#"#[serde(rename_all = "snake_case")] - pub struct User { pub id: i32, pub user_name: String }"#, - )]); - - let tokens = quote!(UserResponse from User); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should use snake_case from source - assert!(output.contains("rename_all")); - assert!(output.contains("snake_case")); -} - -#[test] -fn test_generate_schema_type_code_override_rename_all() { - // Source has snake_case, but we override with camelCase - let storage = to_storage(vec![create_test_struct_metadata( - "User", - r#"#[serde(rename_all = "snake_case")] - pub struct User { pub id: i32, pub user_name: String }"#, - )]); - - let tokens = quote!(UserResponse from User, rename_all = "camelCase"); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should use camelCase (our override) - assert!(output.contains("camelCase")); -} - -// Tests for field rename processing - -#[test] -fn test_generate_schema_type_code_with_rename() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(UserDTO from User, rename = [("id", "user_id")]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("user_id")); - // The From impl should map user_id from source.id - assert!(output.contains("From")); -} - -#[test] -fn test_generate_schema_type_code_rename_preserves_serde_rename() { - // Source field already has serde(rename), which should be preserved as the JSON name - let storage = to_storage(vec![create_test_struct_metadata( - "User", - r#"pub struct User { - pub id: i32, - #[serde(rename = "userName")] - pub name: String - }"#, - )]); - - let tokens = quote!(UserDTO from User, rename = [("name", "user_name")]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // The Rust field is renamed to user_name - assert!(output.contains("user_name")); - // The JSON name should be preserved as userName - assert!(output.contains("userName") || output.contains("rename")); -} - -// Tests for schema derive and name attribute generation - -#[test] -fn test_generate_schema_type_code_with_ignore_schema() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(UserInternal from User, ignore); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should NOT contain vespera::Schema derive - assert!(!output.contains("vespera :: Schema")); -} - -#[test] -fn test_generate_schema_type_code_with_custom_name() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(UserResponse from User, name = "CustomUserSchema"); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should contain schema(name = "...") attribute - assert!(output.contains("schema")); - assert!(output.contains("CustomUserSchema")); - // Metadata should be returned - assert!(metadata.is_some()); - let meta = metadata.unwrap(); - assert_eq!(meta.name, "CustomUserSchema"); -} - -#[test] -fn test_generate_schema_type_code_with_clone_false() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(UserNonClone from User, clone = false); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should NOT contain Clone derive - assert!(!output.contains("Clone ,")); -} - -// Test for SeaORM model detection - -#[test] -fn test_generate_schema_type_code_seaorm_model_detection() { - // Source struct has sea_orm attribute - should be detected as SeaORM model - let storage = to_storage(vec![create_test_struct_metadata( - "Model", - r#"#[sea_orm(table_name = "users")] - pub struct Model { pub id: i32, pub name: String }"#, - )]); - - let tokens = quote!(UserSchema from Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("UserSchema")); -} - -// Test tuple struct handling - -#[test] -fn test_generate_schema_type_code_tuple_struct() { - // Tuple structs have no named fields - let storage = to_storage(vec![create_test_struct_metadata( - "Point", - "pub struct Point(pub i32, pub i32);", - )]); - - let tokens = quote!(PointDTO from Point); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("PointDTO")); -} - -// Test raw identifier fields - -#[test] -fn test_generate_schema_type_code_raw_identifier_field() { - // Field name is a Rust keyword with r# prefix - let storage = to_storage(vec![create_test_struct_metadata( - "Config", - "pub struct Config { pub id: i32, pub r#type: String }", - )]); - - let tokens = quote!(ConfigDTO from Config); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("ConfigDTO")); -} - -// Test Option field not double-wrapped with partial - -#[test] -fn test_generate_schema_type_code_partial_no_double_option() { - // bio is already Option, partial should NOT wrap it again - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub bio: Option }", - )]); - - let tokens = quote!(UpdateUser from User, partial); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // bio should remain Option, not Option> - assert!(!output.contains("Option < Option")); -} - -// Test serde(skip) fields are excluded - -#[test] -fn test_generate_schema_code_excludes_serde_skip_fields() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - r"pub struct User { - pub id: i32, - #[serde(skip)] - pub internal_state: String, - pub name: String - }", - )]); - - let tokens = quote!(User); - let input: SchemaInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_code(&input, &storage); - - assert!(result.is_ok()); - let output = result.unwrap().to_string(); - // internal_state should be excluded from schema properties - assert!(!output.contains("internal_state")); - assert!(output.contains("name")); -} - -// Tests for qualified path storage fallback -// Note: This tests the case where is_qualified_path returns true -// and we find the struct in schema_storage rather than via file lookup - -#[test] -fn test_generate_schema_type_code_qualified_path_storage_lookup() { - // Use a qualified path like crate::models::user::Model - // The storage contains Model, so it should fallback to storage lookup - let storage = to_storage(vec![create_test_struct_metadata( - "Model", - "pub struct Model { pub id: i32, pub name: String }", - )]); - - // Note: This qualified path won't find files (no real filesystem), - // so it falls back to storage lookup by the simple name "Model" - let tokens = quote!(UserSchema from crate::models::user::Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - // This should succeed by finding Model in storage - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("UserSchema")); -} - -// Test for qualified path not found error - -#[test] -fn test_generate_schema_type_code_qualified_path_not_found() { - // Empty storage - qualified path should fail - let storage: HashMap = HashMap::new(); - - let tokens = quote!(UserSchema from crate::models::user::NonExistent); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - // Should fail with "not found" error - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("not found")); -} - -// Tests for HasMany excluded by default - -#[test] -fn test_generate_schema_type_code_has_many_excluded_by_default() { - // SeaORM model with HasMany relation - should be excluded by default - let storage = to_storage(vec![create_test_struct_metadata( - "Model", - r#"#[sea_orm(table_name = "users")] - pub struct Model { - pub id: i32, - pub name: String, - pub memos: HasMany - }"#, - )]); - - let tokens = quote!(UserSchema from Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // HasMany field should NOT appear in output (excluded by default) - assert!(!output.contains("memos")); - // But regular fields should appear - assert!(output.contains("name")); -} - -// Test for relation conversion failure skip - -#[test] -fn test_generate_schema_type_code_relation_conversion_failure() { - // Model with relation type but missing generic args - conversion should fail - // The field should be skipped - let storage = to_storage(vec![create_test_struct_metadata( - "Model", - r#"#[sea_orm(table_name = "users")] - pub struct Model { - pub id: i32, - pub name: String, - pub broken: HasMany - }"#, - )]); - - let tokens = quote!(UserSchema from Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - // Should succeed but skip the broken field - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Broken field should be skipped - assert!(!output.contains("broken")); - // Regular fields should appear - assert!(output.contains("name")); -} - -// Coverage test for BelongsTo relation type conversion - -#[test] -fn test_generate_schema_type_code_belongs_to_relation() { - // SeaORM model with BelongsTo relation - should be included - let storage = to_storage(vec![create_test_struct_metadata( - "Model", - r#"#[sea_orm(table_name = "memos")] - pub struct Model { - pub id: i32, - pub user_id: i32, - #[sea_orm(belongs_to = "super::user::Entity", from = "user_id")] - pub user: BelongsTo - }"#, - )]); - - let tokens = quote!(MemoSchema from Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // BelongsTo should be included (converted to Box or similar) - assert!(output.contains("user")); -} - -// Coverage test for HasOne relation type - -#[test] -fn test_generate_schema_type_code_has_one_relation() { - // SeaORM model with HasOne relation - should be included - let storage = to_storage(vec![create_test_struct_metadata( - "Model", - r#"#[sea_orm(table_name = "users")] - pub struct Model { - pub id: i32, - pub name: String, - pub profile: HasOne - }"#, - )]); - - let tokens = quote!(UserSchema from Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // HasOne should be included - assert!(output.contains("profile")); -} - -// Test for relation fields push into relation_fields - -#[test] -fn test_generate_schema_type_code_seaorm_model_with_relation_generates_from_model() { - // When a SeaORM model has FK relations (HasOne/BelongsTo), - // it should generate from_model impl instead of From impl - let storage = to_storage(vec![create_test_struct_metadata( - "Model", - r#"#[sea_orm(table_name = "memos")] - pub struct Model { - pub id: i32, - pub title: String, - pub user: BelongsTo - }"#, - )]); - - let tokens = quote!(MemoSchema from Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should have relation field - assert!(output.contains("user")); - // Should NOT have regular From impl (because of relation) - // The From impl is only generated when there are no relation fields -} - -// Test for from_model generation with relations -// Note: This requires is_source_seaorm_model && has_relation_fields -// The from_model generation happens but needs file lookup for full path - -#[test] -fn test_generate_schema_type_code_from_model_generation() { - // SeaORM model with relation should trigger from_model generation - let storage = to_storage(vec![create_test_struct_metadata( - "Model", - r#"#[sea_orm(table_name = "memos")] - pub struct Model { - pub id: i32, - pub user: BelongsTo - }"#, - )]); - - let tokens = quote!(MemoSchema from Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Has relation field - assert!(output.contains("user")); - // Regular impl From should NOT be present (because has relations) - // Check that we don't have "impl From < Model > for MemoSchema" - // (Relations disable the automatic From impl) -} - -#[test] -#[serial] -fn test_generate_schema_type_code_qualified_path_file_lookup_success() { - // Tests: qualified path found via file lookup, module_path used when source is empty - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create user.rs with Model struct - let user_model = r" -pub struct Model { - pub id: i32, - pub name: String, - pub email: String, -} -"; - std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - - // Save original CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: This is a test that runs single-threaded - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Use qualified path - file lookup should succeed - let tokens = quote!(UserSchema from crate::models::user::Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let storage: HashMap = HashMap::new(); // Empty storage - force file lookup - - let result = generate_schema_type_code(&input, &storage); - - // Restore CARGO_MANIFEST_DIR - // SAFETY: This is a test that runs single-threaded - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("UserSchema")); - assert!(output.contains("id")); - assert!(output.contains("name")); - assert!(output.contains("email")); -} - -#[test] -#[serial] -fn test_generate_schema_type_code_simple_name_file_lookup_fallback() { - // Tests: simple name (not in storage) found via file lookup with schema_name hint - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create user.rs with Model struct - let user_model = r" -pub struct Model { - pub id: i32, - pub username: String, -} -"; - std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - - // Save original CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: This is a test that runs single-threaded - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Use simple name with schema_name hint - file lookup should find it via hint - // name = "UserSchema" provides hint to look in user.rs - let tokens = quote!(Schema from Model, name = "UserSchema"); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let storage: HashMap = HashMap::new(); // Empty storage - force file lookup - - let result = generate_schema_type_code(&input, &storage); - - // Restore CARGO_MANIFEST_DIR - // SAFETY: This is a test that runs single-threaded - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_ok()); - let (tokens, metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("Schema")); - assert!(output.contains("id")); - assert!(output.contains("username")); - // Metadata should be returned for custom name - assert!(metadata.is_some()); - assert_eq!(metadata.unwrap().name, "UserSchema"); -} - -// ============================================================ -// Tests for HasMany explicit pick with inline type -// ============================================================ - -#[test] -#[serial] -fn test_generate_schema_type_code_has_many_explicit_pick_inline_type() { - // Tests: HasMany is explicitly picked, inline type is generated - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create memo.rs with Model struct (the target of HasMany) - let memo_model = r" -pub struct Model { - pub id: i32, - pub title: String, - pub content: String, -} -"; - std::fs::write(models_dir.join("memo.rs"), memo_model).unwrap(); - - // Create user.rs with Model struct that has HasMany relation - let user_model = r#" -#[sea_orm(table_name = "users")] -pub struct Model { - pub id: i32, - pub name: String, - pub memos: HasMany, -} -"#; - std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - - // Save original CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: This is a test that runs single-threaded - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Explicitly pick HasMany field - should generate inline type - let tokens = quote!(UserSchema from crate::models::user::Model, pick = ["id", "name", "memos"]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let storage: HashMap = HashMap::new(); - - let result = generate_schema_type_code(&input, &storage); - - // Restore CARGO_MANIFEST_DIR - // SAFETY: This is a test that runs single-threaded - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should have inline type definition for memos - assert!(output.contains("UserSchema")); - assert!(output.contains("memos")); - // Inline type should be Vec - assert!(output.contains("Vec <")); -} - -#[test] -#[serial] -fn test_generate_schema_type_code_has_many_explicit_pick_file_not_found() { - // Tests: HasMany is explicitly picked but target file not found - should skip field - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create user.rs with Model struct that has HasMany to nonexistent model - let user_model = r#" -#[sea_orm(table_name = "users")] -pub struct Model { - pub id: i32, - pub name: String, - pub items: HasMany, -} -"#; - std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - - // Save original CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: This is a test that runs single-threaded - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Explicitly pick HasMany field - file not found, should skip - let tokens = quote!(UserSchema from crate::models::user::Model, pick = ["id", "name", "items"]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let storage: HashMap = HashMap::new(); - - let result = generate_schema_type_code(&input, &storage); - - // Restore CARGO_MANIFEST_DIR - // SAFETY: This is a test that runs single-threaded - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // items field should be skipped (file not found for inline type) - assert!(!output.contains("items")); - // But other fields should exist - assert!(output.contains("id")); - assert!(output.contains("name")); -} - -#[test] -fn test_derive_response_base_name_handles_known_suffixes_and_fallback() { - assert_eq!(derive_response_base_name("UserResponse"), "User"); - assert_eq!(derive_response_base_name("UserRequest"), "User"); - assert_eq!(derive_response_base_name("UserSchema"), "User"); - assert_eq!(derive_response_base_name("User"), "User"); -} - -#[test] -fn test_find_same_file_struct_metadata_reads_test_fixture_from_current_module() { - let storage: HashMap = HashMap::new(); - let metadata = find_same_file_struct_metadata("__VesperaSameFileLookupFixture", &storage) - .expect("fixture should be found in schema_macro/mod.rs"); - - assert_eq!(metadata.name, "__VesperaSameFileLookupFixture"); - assert!( - metadata - .definition - .contains("__VesperaSameFileLookupFixture") - ); - assert!(metadata.definition.contains("value")); -} - -#[test] -fn test_has_derive_ignores_non_derive_attrs_and_detects_requested_derive() { - let struct_item: syn::ItemStruct = syn::parse_str( - r#" - #[serde(rename_all = "camelCase")] - #[derive(Clone, Debug)] - struct Sample { - value: i32, - } - "#, - ) - .unwrap(); - - assert!(has_derive(&struct_item, "Clone")); - assert!(!has_derive(&struct_item, "Deserialize")); -} - -#[test] -fn test_build_named_struct_field_assignments_rejects_tuple_structs() { - let struct_item: syn::ItemStruct = syn::parse_str("struct TupleDto(String);").unwrap(); - let source_expr = quote!(source); - let error = build_named_struct_field_assignments(&struct_item, &source_expr).unwrap_err(); - assert!(error.to_string().contains("named-field struct")); -} - -#[test] -fn test_build_proxy_fields_rejects_tuple_structs() { - let struct_item: syn::ItemStruct = syn::parse_str("struct TupleDto(String);").unwrap(); - let error = build_proxy_fields(&struct_item).unwrap_err(); - assert!(error.to_string().contains("named-field struct")); -} - -#[test] -fn test_build_proxy_to_dto_assignments_rejects_tuple_structs() { - let struct_item: syn::ItemStruct = syn::parse_str("struct TupleDto(String);").unwrap(); - let error = build_proxy_to_dto_assignments(&struct_item).unwrap_err(); - assert!(error.to_string().contains("named-field struct")); -} - -#[test] -fn test_build_clone_assignments_rejects_tuple_structs() { - let struct_item: syn::ItemStruct = syn::parse_str("struct TupleDto(String);").unwrap(); - let error = build_clone_assignments(&struct_item).unwrap_err(); - assert!(error.to_string().contains("named-field struct")); -} - -#[test] -fn test_maybe_generate_same_file_relation_override_returns_none_when_dto_is_missing() { - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), - relation_type: "HasOne".to_string(), - schema_path: quote!(crate::models::user::Schema), - is_optional: true, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - - let storage: HashMap = HashMap::new(); - let new_type_name = syn::Ident::new("ArticleResponse", proc_macro2::Span::call_site()); - - let result = - maybe_generate_same_file_relation_override(&new_type_name, "user", &rel_info, &storage) - .expect("missing dto should not error"); - assert!(result.is_none()); -} - -#[test] -fn test_maybe_generate_same_file_relation_override_returns_none_for_invalid_model_type() { - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), - relation_type: "HasOne".to_string(), - schema_path: quote!(?), - is_optional: true, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - - let storage = to_storage(vec![create_test_struct_metadata( - "UserInArticle", - "struct UserInArticle { id: i32 }", - )]); - let new_type_name = syn::Ident::new("ArticleResponse", proc_macro2::Span::call_site()); - - let result = - maybe_generate_same_file_relation_override(&new_type_name, "user", &rel_info, &storage) - .expect("invalid model type should not error"); - assert!(result.is_none()); -} - -#[test] -fn test_generate_schema_type_code_normal_mode_relation_rename_and_custom_name() { - let storage = to_storage(vec![create_test_struct_metadata( - "Model", - r#"#[sea_orm(table_name = "articles")] - pub struct Model { - pub id: i32, - pub name: String, - pub owner: HasOne - }"#, - )]); - - let tokens = quote!( - ArticleResponse from Model, - name = "CustomArticleSchema", - rename = [("name", "display_name")] - ); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("display_name")); - assert!(output.contains("owner")); - assert!(output.contains("Clone")); - assert!(output.contains("CustomArticleSchema")); - assert_eq!(metadata.unwrap().name, "CustomArticleSchema"); -} - -#[test] -fn test_generate_schema_type_code_multipart_with_add_and_custom_name() { - let storage = to_storage(vec![create_test_struct_metadata( - "Upload", - "pub struct Upload { pub id: i32, pub name: String }", - )]); - - let tokens = quote!( - UploadForm from Upload, - multipart, - name = "UploadFormSchema", - add = [("extra": String)] - ); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("vespera :: Multipart")); - assert!(output.contains("extra")); - assert!(output.contains("UploadFormSchema")); - assert_eq!(metadata.unwrap().name, "UploadFormSchema"); -} - -// ============================================================ -// Tests for BelongsTo/HasOne circular reference inline types -// ============================================================ - -#[test] -#[serial] -fn test_generate_schema_type_code_belongs_to_circular_inline_optional() { - // Tests: BelongsTo with circular reference, optional field (is_optional = true) - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create user.rs with Model that references memo (circular) - let user_model = r#" -#[sea_orm(table_name = "users")] -pub struct Model { - pub id: i32, - pub name: String, - pub memo: BelongsTo, -} -"#; - std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - - // Create memo.rs with Model that references user (completing the circle) - let memo_model = r#" -#[sea_orm(table_name = "memos")] -pub struct Model { - pub id: i32, - pub title: String, - pub user_id: i32, - pub user: BelongsTo, -} -"#; - std::fs::write(models_dir.join("memo.rs"), memo_model).unwrap(); - - // Save original CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: This is a test that runs single-threaded - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Generate schema from memo - has BelongsTo user which has circular ref back - let tokens = quote!(MemoSchema from crate::models::memo::Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let storage: HashMap = HashMap::new(); - - let result = generate_schema_type_code(&input, &storage); - - // Restore CARGO_MANIFEST_DIR - // SAFETY: This is a test that runs single-threaded - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should have inline type definition for circular relation - assert!(output.contains("MemoSchema")); - assert!(output.contains("user")); - // BelongsTo is optional by default, so should have Option> - assert!(output.contains("Option < Box <")); -} - -#[test] -#[serial] -fn test_generate_schema_type_code_has_one_circular_inline_required() { - // Tests: HasOne with circular reference, required field (is_optional = false) - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create profile.rs with Model that references user (circular) - let profile_model = r#" -#[sea_orm(table_name = "profiles")] -pub struct Model { - pub id: i32, - pub bio: String, - pub user: BelongsTo, -} -"#; - std::fs::write(models_dir.join("profile.rs"), profile_model).unwrap(); - - // Create user.rs with Model that has HasOne profile - // HasOne with required FK becomes required (non-optional) - let user_model = r#" -#[sea_orm(table_name = "users")] -pub struct Model { - pub id: i32, - pub name: String, - pub profile_id: i32, - #[sea_orm(has_one = "super::profile::Entity", from = "profile_id")] - pub profile: HasOne, -} -"#; - std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - - // Save original CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: This is a test that runs single-threaded - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Generate schema from user - has HasOne profile which has circular ref back - let tokens = quote!(UserSchema from crate::models::user::Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let storage: HashMap = HashMap::new(); - - let result = generate_schema_type_code(&input, &storage); - - // Restore CARGO_MANIFEST_DIR - // SAFETY: This is a test that runs single-threaded - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should have inline type definition for circular relation - assert!(output.contains("UserSchema")); - assert!(output.contains("profile")); - // HasOne with required FK should have Box<...> (not Option>) - assert!(output.contains("Box <")); -} - -#[test] -#[serial] -fn test_generate_schema_type_code_belongs_to_circular_inline_required_file() { - // Tests: BelongsTo with circular reference AND required FK (is_optional = false) - // This requires file-based lookup with: - // 1. #[sea_orm(from = "required_fk")] where required_fk is NOT Option - // 2. Circular reference between two models - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create user.rs with Model that references memo (circular) - let user_model = r#" -#[sea_orm(table_name = "users")] -pub struct Model { - pub id: i32, - pub name: String, - pub memo_id: i32, - #[sea_orm(belongs_to, from = "memo_id", to = "id")] - pub memo: BelongsTo, -} -"#; - std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - - // Create memo.rs with Model that references user (completing the circle) - // Note: using flag-style `belongs_to` with `from = "user_id"` - let memo_model = r#" -#[sea_orm(table_name = "memos")] -pub struct Model { - pub id: i32, - pub title: String, - pub user_id: i32, - #[sea_orm(belongs_to, from = "user_id", to = "id")] - pub user: BelongsTo, -} -"#; - std::fs::write(models_dir.join("memo.rs"), memo_model).unwrap(); - - // Save original CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: This is a test that runs single-threaded - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Generate schema from memo - has BelongsTo user which has circular ref back - // The user_id field is required (not Option), so is_optional = false - // This should generate Box<...> instead of Option> - let tokens = quote!(MemoSchema from crate::models::memo::Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let storage: HashMap = HashMap::new(); - - let result = generate_schema_type_code(&input, &storage); - - // Restore CARGO_MANIFEST_DIR - // SAFETY: This is a test that runs single-threaded - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_ok(), "Should generate schema: {:?}", result.err()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should have inline type definition for circular relation - assert!( - output.contains("MemoSchema"), - "Should contain MemoSchema: {output}" - ); - assert!( - output.contains("user"), - "Should contain user field: {output}" - ); - // BelongsTo with required FK (user_id: i32) should generate Box<...> not Option> - assert!( - output.contains("pub user : Box <"), - "BelongsTo with required FK should generate Box<>, not Option>. Output: {output}" - ); -} - -#[test] -fn test_seaorm_relation_required_fk_directly() { - // Test the convert_relation_type_to_schema_with_info function directly - // to verify is_optional = false when FK is required - use crate::schema_macro::seaorm::{ - convert_relation_type_to_schema_with_info, extract_belongs_to_from_field, - is_field_optional_in_struct, - }; - - // Use the same attribute format that works in seaorm tests: belongs_to (flag), not belongs_to = "..." - let struct_def = r#" -#[sea_orm(table_name = "memos")] -pub struct Model { - pub id: i32, - pub user_id: i32, - #[sea_orm(belongs_to, from = "user_id", to = "id")] - pub user: BelongsTo, -} -"#; - let parsed_struct: syn::ItemStruct = syn::parse_str(struct_def).unwrap(); - - // Get the user field - let syn::Fields::Named(fields_named) = &parsed_struct.fields else { - panic!("Expected named fields") - }; - - let user_field = fields_named - .named - .iter() - .find(|f| f.ident.as_ref().is_some_and(|i| i == "user")) - .expect("user field not found"); - - // Debug: Check if extract_belongs_to_from_field works - let fk_field = extract_belongs_to_from_field(&user_field.attrs); - assert_eq!( - fk_field, - Some("user_id".to_string()), - "Should extract FK field from attribute" - ); - - // Debug: Check if is_field_optional_in_struct works - let is_fk_optional = is_field_optional_in_struct(&parsed_struct, "user_id"); - assert!(!is_fk_optional, "user_id: i32 should not be optional"); - - let result = convert_relation_type_to_schema_with_info( - &user_field.ty, - &user_field.attrs, - &parsed_struct, - &[ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ], - user_field.ident.clone().unwrap(), - ); - - assert!(result.is_some(), "Should convert BelongsTo relation"); - let (_, rel_info) = result.unwrap(); - assert_eq!(rel_info.relation_type, "BelongsTo"); - // The FK field user_id is i32 (not Option), so is_optional should be false - assert!( - !rel_info.is_optional, - "BelongsTo with required FK (user_id: i32) should have is_optional = false" - ); -} - -#[test] -fn test_extract_belongs_to_from_field_with_equals_value() { - // Test that extract_belongs_to_from_field works with belongs_to = "..." format - use crate::schema_macro::seaorm::extract_belongs_to_from_field; - - // Format 1: belongs_to (flag style) - known to work - let attrs1: Vec = vec![syn::parse_quote!( - #[sea_orm(belongs_to, from = "user_id", to = "id")] - )]; - let result1 = extract_belongs_to_from_field(&attrs1); - assert_eq!( - result1, - Some("user_id".to_string()), - "Flag style should work" - ); - - // Format 2: belongs_to = "..." (value style) - testing this - let attrs2: Vec = vec![syn::parse_quote!( - #[sea_orm(belongs_to = "super::user::Entity", from = "user_id", to = "id")] - )]; - let result2 = extract_belongs_to_from_field(&attrs2); - assert_eq!( - result2, - Some("user_id".to_string()), - "Value style should also work" - ); -} - -// ============================================================ -// Tests for multipart mode -// ============================================================ - -#[test] -fn test_generate_schema_type_code_multipart_basic() { - // Tests: multipart mode generates Multipart derive, suppresses From impl - let storage = to_storage(vec![create_test_struct_metadata( - "UploadRequest", - "pub struct UploadRequest { pub name: String, pub description: Option }", - )]); - - let tokens = quote!(PatchUpload from UploadRequest, multipart); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should derive Multipart - assert!(output.contains("Multipart")); - // Should NOT have From impl (multipart suppresses it) - assert!(!output.contains("impl From")); - // Should have the struct fields - assert!(output.contains("name")); - assert!(output.contains("description")); -} - -#[test] -fn test_generate_schema_type_code_multipart_with_rename() { - // Tests: multipart mode with field rename - let storage = to_storage(vec![create_test_struct_metadata( - "UploadRequest", - "pub struct UploadRequest { pub name: String, pub file_path: String }", - )]); - - let tokens = quote!(RenamedUpload from UploadRequest, multipart, rename = [("file_path", "document_path")]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should derive Multipart - assert!(output.contains("Multipart")); - // Should have renamed field - assert!(output.contains("document_path")); - // Original name should NOT appear as field - assert!(!output.contains("file_path")); -} - -#[test] -fn test_generate_schema_type_code_multipart_with_form_data_attrs() { - // Tests: multipart mode preserves #[form_data] attributes from source - let storage = to_storage(vec![create_test_struct_metadata( - "UploadRequest", - r#"pub struct UploadRequest { - pub name: String, - #[form_data(limit = "10MiB")] - pub file: String - }"#, - )]); - - let tokens = quote!(PatchUpload from UploadRequest, multipart); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should preserve form_data attributes - assert!(output.contains("form_data")); - assert!(output.contains("limit")); -} - -#[test] -fn test_generate_schema_type_code_multipart_skips_relations() { - // Tests: multipart mode skips relation fields - let storage = to_storage(vec![create_test_struct_metadata( - "Model", - r#"#[sea_orm(table_name = "memos")] - pub struct Model { - pub id: i32, - pub title: String, - pub user: BelongsTo - }"#, - )]); - - let tokens = quote!(MemoUpload from Model, multipart); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Relation field should be skipped in multipart mode - assert!(!output.contains("user")); - // Regular fields should be present - assert!(output.contains("id")); - assert!(output.contains("title")); - // Should derive Multipart - assert!(output.contains("Multipart")); -} - -#[test] -fn test_generate_schema_type_code_multipart_partial() { - // Coverage for multipart + partial combination - let storage = to_storage(vec![create_test_struct_metadata( - "UploadRequest", - "pub struct UploadRequest { pub name: String, pub tags: String }", - )]); - - let tokens = quote!(PatchUpload from UploadRequest, multipart, partial); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should derive Multipart - assert!(output.contains("Multipart")); - // Fields should be wrapped in Option (partial) - assert!(output.contains("Option")); - // Should NOT have From impl - assert!(!output.contains("impl From")); -} - -#[test] -#[serial] -fn test_generate_schema_type_code_qualified_path_with_nonempty_module_path() { - // Tests: qualified path with explicit module segments that are not empty - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create user.rs - let user_model = r" -pub struct Model { - pub id: i32, - pub name: String, -} -"; - std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - - // Save original CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: This is a test that runs single-threaded - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // crate::models::user::Model - this is a qualified path - // extract_module_path should return ["crate", "models", "user"] - // So the if source_module_path.is_empty() check should be false - let tokens = quote!(UserSchema from crate::models::user::Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let storage: HashMap = HashMap::new(); - - let result = generate_schema_type_code(&input, &storage); - - // Restore CARGO_MANIFEST_DIR - // SAFETY: This is a test that runs single-threaded - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("UserSchema")); -} - -#[test] -#[serial] -fn test_generate_schema_type_code_cross_module_json_alias_uses_public_path() { - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - let routes_dir = src_dir.join("routes"); - std::fs::create_dir_all(&models_dir).unwrap(); - std::fs::create_dir_all(&routes_dir).unwrap(); - - let json_case_model = r#" -use sea_orm::entity::prelude::*; - -#[sea_orm::model] -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] -#[sea_orm(table_name = "json_case")] -pub struct Model { - #[sea_orm(primary_key)] - pub id: i32, - pub payload: Json, -} - -impl ActiveModelBehavior for ActiveModel {} -"#; - std::fs::write(models_dir.join("json_case.rs"), json_case_model).unwrap(); - std::fs::write( - routes_dir.join("json_case.rs"), - "vespera::schema_type!(RouteJsonCaseSchema from crate::models::json_case::Model);", - ) - .unwrap(); - - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let tokens = quote!(RouteJsonCaseSchema from crate::models::json_case::Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let storage: HashMap = HashMap::new(); - let result = generate_schema_type_code(&input, &storage); - - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("pub payload : vespera :: serde_json :: Value")); - assert!(!output.contains("crate :: models :: json_case :: Json")); -} diff --git a/crates/vespera_macro/src/schema_macro/transformation.rs b/crates/vespera_macro/src/schema_macro/transformation.rs index ce2dce6c..543faf81 100644 --- a/crates/vespera_macro/src/schema_macro/transformation.rs +++ b/crates/vespera_macro/src/schema_macro/transformation.rs @@ -440,3 +440,450 @@ mod tests { )); } } + +#[cfg(test)] +mod schema_type_option_tests { + use std::collections::HashMap; + + use quote::quote; + + use crate::metadata::StructMetadata; + use crate::schema_macro::{ + SchemaInput, SchemaTypeInput, generate_schema_code, generate_schema_type_code, + }; + + fn create_test_struct_metadata(name: &str, definition: &str) -> StructMetadata { + StructMetadata::new(name.to_string(), definition.to_string()) + } + + fn to_storage(items: Vec) -> HashMap { + items.into_iter().map(|s| (s.name.clone(), s)).collect() + } + + // Tests for field rename processing + + #[test] + fn test_generate_schema_type_code_with_rename() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(UserDTO from User, rename = [("id", "user_id")]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("user_id")); + // The From impl should map user_id from source.id + assert!(output.contains("From")); + } + + #[test] + fn test_generate_schema_type_code_rename_preserves_serde_rename() { + // Source field already has serde(rename), which should be preserved as the JSON name + let storage = to_storage(vec![create_test_struct_metadata( + "User", + r#"pub struct User { + pub id: i32, + #[serde(rename = "userName")] + pub name: String + }"#, + )]); + + let tokens = quote!(UserDTO from User, rename = [("name", "user_name")]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // The Rust field is renamed to user_name + assert!(output.contains("user_name")); + // The JSON name should be preserved as userName + assert!(output.contains("userName") || output.contains("rename")); + } + + // Tests for schema derive and name attribute generation + + #[test] + fn test_generate_schema_type_code_with_ignore_schema() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(UserInternal from User, ignore); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should NOT contain vespera::Schema derive + assert!(!output.contains("vespera :: Schema")); + } + + #[test] + fn test_generate_schema_type_code_with_custom_name() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(UserResponse from User, name = "CustomUserSchema"); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should contain schema(name = "...") attribute + assert!(output.contains("schema")); + assert!(output.contains("CustomUserSchema")); + // Metadata should be returned + assert!(metadata.is_some()); + let meta = metadata.unwrap(); + assert_eq!(meta.name, "CustomUserSchema"); + } + + #[test] + fn test_generate_schema_type_code_with_clone_false() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(UserNonClone from User, clone = false); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should NOT contain Clone derive + assert!(!output.contains("Clone ,")); + } + + // Test for SeaORM model detection + + #[test] + fn test_generate_schema_type_code_seaorm_model_detection() { + // Source struct has sea_orm attribute - should be detected as SeaORM model + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "users")] + pub struct Model { pub id: i32, pub name: String }"#, + )]); + + let tokens = quote!(UserSchema from Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("UserSchema")); + } + + // Test tuple struct handling + + #[test] + fn test_generate_schema_type_code_tuple_struct() { + // Tuple structs have no named fields + let storage = to_storage(vec![create_test_struct_metadata( + "Point", + "pub struct Point(pub i32, pub i32);", + )]); + + let tokens = quote!(PointDTO from Point); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("PointDTO")); + } + + // Test raw identifier fields + + #[test] + fn test_generate_schema_type_code_raw_identifier_field() { + // Field name is a Rust keyword with r# prefix + let storage = to_storage(vec![create_test_struct_metadata( + "Config", + "pub struct Config { pub id: i32, pub r#type: String }", + )]); + + let tokens = quote!(ConfigDTO from Config); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("ConfigDTO")); + } + + // Test Option field not double-wrapped with partial + + #[test] + fn test_generate_schema_type_code_partial_no_double_option() { + // bio is already Option, partial should NOT wrap it again + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub bio: Option }", + )]); + + let tokens = quote!(UpdateUser from User, partial); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // bio should remain Option, not Option> + assert!(!output.contains("Option < Option")); + } + + // Test serde(skip) fields are excluded + + #[test] + fn test_generate_schema_code_excludes_serde_skip_fields() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + r"pub struct User { + pub id: i32, + #[serde(skip)] + pub internal_state: String, + pub name: String + }", + )]); + + let tokens = quote!(User); + let input: SchemaInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_code(&input, &storage); + + assert!(result.is_ok()); + let output = result.unwrap().to_string(); + // internal_state should be excluded from schema properties + assert!(!output.contains("internal_state")); + assert!(output.contains("name")); + } + + // Tests for qualified path storage fallback + // Note: This tests the case where is_qualified_path returns true + // and we find the struct in schema_storage rather than via file lookup + + #[test] + fn test_generate_schema_type_code_qualified_path_storage_lookup() { + // Use a qualified path like crate::models::user::Model + // The storage contains Model, so it should fallback to storage lookup + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + "pub struct Model { pub id: i32, pub name: String }", + )]); + + // Note: This qualified path won't find files (no real filesystem), + // so it falls back to storage lookup by the simple name "Model" + let tokens = quote!(UserSchema from crate::models::user::Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + // This should succeed by finding Model in storage + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("UserSchema")); + } + + // Test for qualified path not found error + + #[test] + fn test_generate_schema_type_code_qualified_path_not_found() { + // Empty storage - qualified path should fail + let storage: HashMap = HashMap::new(); + + let tokens = quote!(UserSchema from crate::models::user::NonExistent); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + // Should fail with "not found" error + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("not found")); + } + + // Tests for HasMany excluded by default + + #[test] + fn test_generate_schema_type_code_has_many_excluded_by_default() { + // SeaORM model with HasMany relation - should be excluded by default + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "users")] + pub struct Model { + pub id: i32, + pub name: String, + pub memos: HasMany + }"#, + )]); + + let tokens = quote!(UserSchema from Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // HasMany field should NOT appear in output (excluded by default) + assert!(!output.contains("memos")); + // But regular fields should appear + assert!(output.contains("name")); + } + + // Test for relation conversion failure skip + + #[test] + fn test_generate_schema_type_code_relation_conversion_failure() { + // Model with relation type but missing generic args - conversion should fail + // The field should be skipped + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "users")] + pub struct Model { + pub id: i32, + pub name: String, + pub broken: HasMany + }"#, + )]); + + let tokens = quote!(UserSchema from Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + // Should succeed but skip the broken field + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Broken field should be skipped + assert!(!output.contains("broken")); + // Regular fields should appear + assert!(output.contains("name")); + } + + // Coverage test for BelongsTo relation type conversion + + #[test] + fn test_generate_schema_type_code_belongs_to_relation() { + // SeaORM model with BelongsTo relation - should be included + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "memos")] + pub struct Model { + pub id: i32, + pub user_id: i32, + #[sea_orm(belongs_to = "super::user::Entity", from = "user_id")] + pub user: BelongsTo + }"#, + )]); + + let tokens = quote!(MemoSchema from Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // BelongsTo should be included (converted to Box or similar) + assert!(output.contains("user")); + } + + // Coverage test for HasOne relation type + + #[test] + fn test_generate_schema_type_code_has_one_relation() { + // SeaORM model with HasOne relation - should be included + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "users")] + pub struct Model { + pub id: i32, + pub name: String, + pub profile: HasOne + }"#, + )]); + + let tokens = quote!(UserSchema from Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // HasOne should be included + assert!(output.contains("profile")); + } + + // Test for relation fields push into relation_fields + + #[test] + fn test_generate_schema_type_code_seaorm_model_with_relation_generates_from_model() { + // When a SeaORM model has FK relations (HasOne/BelongsTo), + // it should generate from_model impl instead of From impl + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "memos")] + pub struct Model { + pub id: i32, + pub title: String, + pub user: BelongsTo + }"#, + )]); + + let tokens = quote!(MemoSchema from Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should have relation field + assert!(output.contains("user")); + // Should NOT have regular From impl (because of relation) + // The From impl is only generated when there are no relation fields + } + + // Test for from_model generation with relations + // Note: This requires is_source_seaorm_model && has_relation_fields + // The from_model generation happens but needs file lookup for full path + + #[test] + fn test_generate_schema_type_code_from_model_generation() { + // SeaORM model with relation should trigger from_model generation + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "memos")] + pub struct Model { + pub id: i32, + pub user: BelongsTo + }"#, + )]); + + let tokens = quote!(MemoSchema from Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Has relation field + assert!(output.contains("user")); + // Regular impl From should NOT be present (because has relations) + // Check that we don't have "impl From < Model > for MemoSchema" + // (Relations disable the automatic From impl) + } +} diff --git a/crates/vespera_macro/src/vespera_impl.rs b/crates/vespera_macro/src/vespera_impl.rs index 6d03f032..5ae90083 100644 --- a/crates/vespera_macro/src/vespera_impl.rs +++ b/crates/vespera_macro/src/vespera_impl.rs @@ -1,1868 +1,16 @@ //! Core implementation of vespera! and `export_app`! macros. //! -//! This module orchestrates the entire macro execution flow: -//! - Route discovery via filesystem scanning -//! - `OpenAPI` spec generation -//! - File I/O for writing `OpenAPI` JSON -//! - Router code generation -//! -//! # Overview -//! -//! This is the main orchestrator for the two primary macros: -//! - `vespera!()` - Generates a complete Axum router with `OpenAPI` spec -//! - `export_app!()` - Exports a router for merging into parent apps -//! -//! The execution flow is: -//! 1. Parse macro arguments via [`router_codegen`] -//! 2. Discover routes via [`collector::collect_metadata`] -//! 3. Generate `OpenAPI` spec via [`openapi_generator`] -//! 4. Write `OpenAPI` JSON files (if configured) -//! 5. Generate router code via [`router_codegen::generate_router_code`] -//! -//! # Key Functions -//! -//! - [`process_vespera_macro`] - Main vespera! macro implementation -//! - [`process_export_app`] - Main `export_app`! macro implementation -//! - [`generate_and_write_openapi`] - `OpenAPI` generation and file I/O - -use std::{ - collections::HashMap, - hash::{Hash, Hasher}, - path::Path, -}; - -use proc_macro2::Span; -use quote::quote; - -use serde::{Deserialize, Serialize}; - -use crate::{ - collector::{collect_file_fingerprints, collect_metadata}, - error::{MacroResult, err_call_site}, - metadata::{CollectedMetadata, StructMetadata}, - openapi_generator::generate_openapi_doc_with_metadata, - route_impl::StoredRouteInfo, - router_codegen::{ProcessedVesperaInput, generate_router_code}, -}; - -/// Docs info tuple type alias for cleaner signatures -pub type DocsInfo = (Option, Option, Option); - -/// Cache for avoiding redundant route scanning and OpenAPI generation. -/// Persisted to `target/vespera/routes.cache` across builds. -#[derive(Serialize, Deserialize)] -struct VesperaCache { - /// Macro crate version — invalidates cache when macro code changes - #[serde(default)] - macro_version: String, - /// File path → modification time (secs since UNIX_EPOCH) - file_fingerprints: HashMap, - /// Hash of SCHEMA_STORAGE contents - schema_hash: u64, - /// Hash of OpenAPI config (title, version, servers, docs_url, etc.) - config_hash: u64, - /// Cached route/struct metadata - metadata: CollectedMetadata, - /// Compact JSON for docs embedding (None if docs disabled) - spec_json: Option, - /// Pretty JSON for file output (None if no openapi file configured) - spec_pretty: Option, -} - -/// Compute a deterministic hash of SCHEMA_STORAGE contents. -fn compute_schema_hash(schema_storage: &HashMap) -> u64 { - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - let mut keys: Vec<&String> = schema_storage.keys().collect(); - keys.sort(); - for key in keys { - key.hash(&mut hasher); - let meta = &schema_storage[key]; - meta.name.hash(&mut hasher); - meta.definition.hash(&mut hasher); - meta.include_in_openapi.hash(&mut hasher); - } - hasher.finish() -} - -/// Compute a deterministic hash of OpenAPI config fields. -fn compute_config_hash(processed: &ProcessedVesperaInput) -> u64 { - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - processed.title.hash(&mut hasher); - processed.version.hash(&mut hasher); - processed.docs_url.hash(&mut hasher); - processed.redoc_url.hash(&mut hasher); - processed.openapi_file_names.hash(&mut hasher); - if let Some(ref servers) = processed.servers { - for s in servers { - s.url.hash(&mut hasher); - } - } - for merge_path in &processed.merge { - quote!(#merge_path).to_string().hash(&mut hasher); - } - hasher.finish() -} - -/// Get the path to the routes cache file. -fn get_cache_path() -> std::path::PathBuf { - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default(); - let manifest_path = Path::new(&manifest_dir); - find_target_dir(manifest_path) - .join("vespera") - .join("routes.cache") -} - -/// Try to read and deserialize a cache file. Returns None on any failure. -fn read_cache(cache_path: &Path) -> Option { - let content = std::fs::read_to_string(cache_path).ok()?; - serde_json::from_str(&content).ok() -} - -/// Write cache to disk. Failures are silently ignored (cache is best-effort). -fn write_cache(cache_path: &Path, cache: &VesperaCache) { - if let Some(parent) = cache_path.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Ok(json) = serde_json::to_string(cache) { - let _ = std::fs::write(cache_path, json); - } -} - -/// Generate `OpenAPI` JSON and write to files, returning docs info -pub fn generate_and_write_openapi( - input: &ProcessedVesperaInput, - metadata: &CollectedMetadata, - file_asts: HashMap, - route_storage: &[StoredRouteInfo], -) -> MacroResult { - if input.openapi_file_names.is_empty() && input.docs_url.is_none() && input.redoc_url.is_none() - { - return Ok((None, None, None)); - } - - let mut openapi_doc = generate_openapi_doc_with_metadata( - input.title.clone(), - input.version.clone(), - input.servers.clone(), - metadata, - Some(file_asts), - route_storage, - ); - - // Merge specs from child apps at compile time - if !input.merge.is_empty() - && let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") - { - let manifest_path = Path::new(&manifest_dir); - let target_dir = find_target_dir(manifest_path); - let vespera_dir = target_dir.join("vespera"); - - for merge_path in &input.merge { - // Extract the struct name (last segment, e.g., "ThirdApp" from "third::ThirdApp") - if let Some(last_segment) = merge_path.segments.last() { - let struct_name = last_segment.ident.to_string(); - let spec_file = vespera_dir.join(format!("{struct_name}.openapi.json")); - - if let Ok(spec_content) = std::fs::read_to_string(&spec_file) - && let Ok(child_spec) = - serde_json::from_str::(&spec_content) - { - openapi_doc.merge(child_spec); - } - } - } - } - - // NOTE on F-01: an earlier audit suggested serialising the - // `OpenApi` document once into `serde_json::Value` and emitting - // pretty + compact from the cached `Value`. We deliberately do - // **not** do that here. Going through `Value` re-orders every - // object's keys alphabetically (because the default - // `serde_json::Map` is `BTreeMap`-backed), which silently changes - // the field order in every user-visible `openapi.json` file. The - // marginal build-time saving is not worth churning the output of a - // file users diff in CI. Keep two direct serialisations. - // - // Pretty-print for user-visible files. - if !input.openapi_file_names.is_empty() { - let json_pretty = serde_json::to_string_pretty(&openapi_doc).map_err(|e| err_call_site(format!("OpenAPI generation: failed to serialize document to JSON. Error: {e}. Check that all schema types are serializable.")))?; - for openapi_file_name in &input.openapi_file_names { - let file_path = Path::new(openapi_file_name); - if let Some(parent) = file_path.parent() { - std::fs::create_dir_all(parent).map_err(|e| err_call_site(format!("OpenAPI output: failed to create directory '{}'. Error: {}. Ensure the path is valid and writable.", parent.display(), e)))?; - } - let should_write = - std::fs::read_to_string(file_path).map_or(true, |existing| existing != json_pretty); - if should_write { - std::fs::write(file_path, &json_pretty).map_err(|e| err_call_site(format!("OpenAPI output: failed to write file '{openapi_file_name}'. Error: {e}. Ensure the file path is writable.")))?; - } - } - } - - // Compact JSON for embedding (smaller binary, faster downstream compilation). - let spec_json = if input.docs_url.is_some() || input.redoc_url.is_some() { - Some(serde_json::to_string(&openapi_doc).map_err(|e| err_call_site(format!("OpenAPI generation: failed to serialize document to JSON. Error: {e}. Check that all schema types are serializable.")))?) - } else { - None - }; - - Ok((input.docs_url.clone(), input.redoc_url.clone(), spec_json)) -} - -/// Find the folder path for route scanning -pub fn find_folder_path(folder_name: &str) -> MacroResult { - let root = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| { - err_call_site( - "CARGO_MANIFEST_DIR is not set. vespera macros must be used within a cargo build.", - ) - })?; - let path = format!("{root}/src/{folder_name}"); - let path = Path::new(&path); - if path.exists() && path.is_dir() { - return Ok(path.to_path_buf()); - } - - Ok(Path::new(folder_name).to_path_buf()) -} - -/// Find the workspace root's target directory -pub fn find_target_dir(manifest_path: &Path) -> std::path::PathBuf { - // Look for workspace root by finding a Cargo.toml with [workspace] section - let mut current = Some(manifest_path); - let mut last_with_lock = None; - - while let Some(dir) = current { - // Check if this directory has Cargo.lock - if dir.join("Cargo.lock").exists() { - last_with_lock = Some(dir.to_path_buf()); - } - - // Check if this is a workspace root (has Cargo.toml with [workspace]). - // `read_to_string` already fails when the file does not exist, so the - // previous `.exists()` pre-flight is redundant — drop it to save one - // stat per iteration of the walk. - if let Ok(contents) = std::fs::read_to_string(dir.join("Cargo.toml")) - && contents.contains("[workspace]") - { - return dir.join("target"); - } - - current = dir.parent(); - } - - // If we found a Cargo.lock but no [workspace], use the topmost one - if let Some(lock_dir) = last_with_lock { - return lock_dir.join("target"); - } - - // Fallback: use manifest dir's target - manifest_path.join("target") -} - -/// Supplement collector's `RouteMetadata` with data from `ROUTE_STORAGE`. -/// -/// `#[route]` stores metadata at attribute expansion time. -/// `collector.rs` re-parses the same data from file ASTs. -/// This function merges ROUTE_STORAGE data into collector's output, -/// preferring ROUTE_STORAGE values when they provide richer info. -/// -/// Matching is by function name. If multiple routes share a function name, -/// the match is ambiguous and ROUTE_STORAGE data is skipped for safety. -fn merge_route_storage_data(metadata: &mut CollectedMetadata, route_storage: &[StoredRouteInfo]) { - if route_storage.is_empty() { - return; - } - - // Build `fn_name -> Option<&StoredRouteInfo>` index in a single pass: - // `Some(_)` when the name is unique, `None` when it is ambiguous - // (appears more than once). This turns the previous O(N*M) nested - // scan into O(N + M). - let mut stored_index: HashMap<&str, Option<&StoredRouteInfo>> = - HashMap::with_capacity(route_storage.len()); - for stored in route_storage { - stored_index - .entry(stored.fn_name.as_str()) - .and_modify(|slot| *slot = None) - .or_insert(Some(stored)); - } - - for route in &mut metadata.routes { - // Skip if no match or ambiguous (multiple routes share fn_name). - let Some(Some(stored)) = stored_index.get(route.function_name.as_str()) else { - continue; - }; - - // Supplement with ROUTE_STORAGE data — only override when an - // explicit value is present. - if let Some(ref tags) = stored.tags { - route.tags = Some(tags.clone()); - } - if let Some(ref desc) = stored.description { - route.description = Some(desc.clone()); - } - if let Some(ref status) = stored.error_status { - route.error_status = Some(status.clone()); - } - } -} - -/// Write cached OpenAPI spec to output files if they are stale or missing. -pub fn ensure_openapi_files_from_cache( - openapi_file_names: &[String], - spec_pretty: Option<&str>, -) -> syn::Result<()> { - let Some(pretty) = spec_pretty else { - return Ok(()); - }; - for openapi_file_name in openapi_file_names { - let file_path = Path::new(openapi_file_name); - let should_write = - std::fs::read_to_string(file_path).map_or(true, |existing| existing != *pretty); - if should_write { - if let Some(parent) = file_path.parent() { - std::fs::create_dir_all(parent).map_err(|e| { - syn::Error::new( - Span::call_site(), - format!( - "OpenAPI output: failed to create directory '{}': {}", - parent.display(), - e - ), - ) - })?; - } - std::fs::write(file_path, pretty).map_err(|e| { - syn::Error::new( - Span::call_site(), - format!("OpenAPI output: failed to write file '{openapi_file_name}': {e}"), - ) - })?; - } - } - Ok(()) -} - -/// Write compact spec JSON to target dir for `include_str!` embedding. -fn write_spec_for_embedding( - spec_json: Option, -) -> syn::Result> { - let Some(json) = spec_json else { - return Ok(None); - }; - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default(); - let manifest_path = Path::new(&manifest_dir); - let target_dir = find_target_dir(manifest_path); - let vespera_dir = target_dir.join("vespera"); - std::fs::create_dir_all(&vespera_dir).map_err(|e| { - syn::Error::new( - Span::call_site(), - format!( - "vespera! macro: failed to create directory '{}': {}", - vespera_dir.display(), - e - ), - ) - })?; - let spec_file = vespera_dir.join("vespera_spec.json"); - let should_write = - std::fs::read_to_string(&spec_file).map_or(true, |existing| existing != json); - if should_write { - std::fs::write(&spec_file, &json).map_err(|e| { - syn::Error::new( - Span::call_site(), - format!( - "vespera! macro: failed to write spec file '{}': {}", - spec_file.display(), - e - ), - ) - })?; - } - let path_str = spec_file.display().to_string().replace('\\', "/"); - Ok(Some(quote::quote! { include_str!(#path_str) })) -} - -/// Process vespera macro - extracted for testability -#[allow(clippy::too_many_lines)] -pub fn process_vespera_macro( - processed: &ProcessedVesperaInput, - schema_storage: &HashMap, - route_storage: &[StoredRouteInfo], -) -> syn::Result { - let profile_start = if std::env::var("VESPERA_PROFILE").is_ok() { - Some(std::time::Instant::now()) - } else { - None - }; - - let folder_path = find_folder_path(&processed.folder_name)?; - if !folder_path.exists() { - return Err(syn::Error::new( - Span::call_site(), - format!( - "vespera! macro: route folder '{}' not found. Create src/{} or specify a different folder with `dir = \"your_folder\"`.", - processed.folder_name, processed.folder_name - ), - )); - } - - // --- Incremental cache check --- - let cache_path = get_cache_path(); - let fingerprints = collect_file_fingerprints(&folder_path) - .map_err(|e| syn::Error::new(Span::call_site(), format!("vespera! macro: {e}")))?; - let schema_hash = compute_schema_hash(schema_storage); - let config_hash = compute_config_hash(processed); - - let macro_version = env!("CARGO_PKG_VERSION").to_string(); - let cached = read_cache(&cache_path); - let cache_hit = cached.as_ref().is_some_and(|c| { - c.macro_version == macro_version - && c.file_fingerprints == fingerprints - && c.schema_hash == schema_hash - && c.config_hash == config_hash - }); - - let (metadata, spec_json) = if cache_hit { - let cache = cached.unwrap(); - let mut metadata = cache.metadata; - metadata.structs.extend(schema_storage.values().cloned()); - merge_route_storage_data(&mut metadata, route_storage); - metadata - .check_duplicate_schema_names() - .map_err(|msg| syn::Error::new(Span::call_site(), format!("vespera! macro: {msg}")))?; - - // Ensure openapi.json files exist and are up-to-date from cache - ensure_openapi_files_from_cache( - &processed.openapi_file_names, - cache.spec_pretty.as_deref(), - )?; - - (metadata, cache.spec_json) - } else { - let (mut metadata, file_asts) = collect_metadata(&folder_path, &processed.folder_name, route_storage).map_err(|e| syn::Error::new(Span::call_site(), format!("vespera! macro: failed to scan route folder '{}'. Error: {}. Check that all .rs files have valid Rust syntax.", processed.folder_name, e)))?; - - // Clone metadata before extending (cache stores file-only structs) - let cache_metadata = metadata.clone(); - metadata.structs.extend(schema_storage.values().cloned()); - merge_route_storage_data(&mut metadata, route_storage); - metadata - .check_duplicate_schema_names() - .map_err(|msg| syn::Error::new(Span::call_site(), format!("vespera! macro: {msg}")))?; - - let (_, _, spec_json) = - generate_and_write_openapi(processed, &metadata, file_asts, route_storage)?; - - // Read back spec_pretty from first openapi file for caching - let spec_pretty = processed - .openapi_file_names - .first() - .and_then(|f| std::fs::read_to_string(f).ok()); - - // Persist cache (best-effort, failures are silent) - write_cache( - &cache_path, - &VesperaCache { - macro_version: macro_version.clone(), - file_fingerprints: fingerprints, - schema_hash, - config_hash, - metadata: cache_metadata, - spec_json: spec_json.clone(), - spec_pretty, - }, - ); - - (metadata, spec_json) - }; - - // Write compact spec for include_str! embedding - let spec_tokens = write_spec_for_embedding(spec_json)?; - - // --- Cron job discovery from CRON_STORAGE --- - // #[cron("...")] attribute already registers metadata at expansion time. - // No folder scanning needed — just read the storage. - let cron_jobs: Vec = { - let storage = crate::CRON_STORAGE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let src_dir = std::env::var("CARGO_MANIFEST_DIR") - .map(|d| { - let p = std::path::PathBuf::from(d).join("src"); - // Canonicalize for reliable prefix stripping - let canonical = p.canonicalize().unwrap_or(p); - canonical.display().to_string().replace('\\', "/") - }) - .unwrap_or_default(); - storage - .iter() - .map(|s| { - // Derive module path from file_path relative to src/ - let module_path = s - .file_path - .as_ref() - .map(|fp| { - let canonical = std::path::Path::new(fp) - .canonicalize() - .map_or_else(|_| fp.clone(), |p| p.display().to_string()); - let normalized = canonical.replace('\\', "/"); - let relative = normalized - .strip_prefix(&src_dir) - .map_or(&*normalized, |rest| rest.trim_start_matches('/')); - // Convert path to module path: strip .rs, replace / with ::, strip mod - // Replace hyphens with underscores (Rust module convention) - relative - .trim_end_matches(".rs") - .replace('/', "::") - .replace('-', "_") - .trim_end_matches("::mod") - .to_string() - }) - .unwrap_or_default(); - crate::metadata::CronMetadata { - expression: s.expression.clone(), - function_name: s.fn_name.clone(), - module_path, - file_path: s.file_path.clone().unwrap_or_default(), - } - }) - .collect() - }; - - let result = Ok(generate_router_code( - &metadata, - processed.docs_url.as_deref(), - processed.redoc_url.as_deref(), - spec_tokens, - &processed.merge, - &cron_jobs, - )); - - if let Some(start) = profile_start { - eprintln!( - "[vespera-profile] vespera! macro total: {:?}", - start.elapsed() - ); - crate::schema_macro::print_profile_summary(); - } - - result -} - -/// Process `export_app` macro - extracted for testability -pub fn process_export_app( - name: &syn::Ident, - folder_name: &str, - schema_storage: &HashMap, - manifest_dir: &str, - route_storage: &[StoredRouteInfo], -) -> syn::Result { - let profile_start = if std::env::var("VESPERA_PROFILE").is_ok() { - Some(std::time::Instant::now()) - } else { - None - }; - - let folder_path = find_folder_path(folder_name)?; - if !folder_path.exists() { - return Err(syn::Error::new( - Span::call_site(), - format!( - "export_app! macro: route folder '{folder_name}' not found. Create src/{folder_name} or specify a different folder with `dir = \"your_folder\"`.", - ), - )); - } - - let (mut metadata, file_asts) = collect_metadata(&folder_path, folder_name, route_storage).map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to scan route folder '{folder_name}'. Error: {e}. Check that all .rs files have valid Rust syntax.")))?; - metadata.structs.extend(schema_storage.values().cloned()); - merge_route_storage_data(&mut metadata, route_storage); - metadata - .check_duplicate_schema_names() - .map_err(|msg| syn::Error::new(Span::call_site(), format!("export_app! macro: {msg}")))?; - - // Generate OpenAPI spec JSON string - let openapi_doc = generate_openapi_doc_with_metadata( - None, - None, - None, - &metadata, - Some(file_asts), - route_storage, - ); - let spec_json = serde_json::to_string(&openapi_doc).map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to serialize OpenAPI spec to JSON. Error: {e}. Check that all schema types are serializable.")))?; - - // Write spec to temp file for compile-time merging by parent apps - let name_str = name.to_string(); - let manifest_path = Path::new(manifest_dir); - let target_dir = find_target_dir(manifest_path); - let vespera_dir = target_dir.join("vespera"); - std::fs::create_dir_all(&vespera_dir).map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to create build cache directory '{}'. Error: {}. Ensure the target directory is writable.", vespera_dir.display(), e)))?; - let spec_file = vespera_dir.join(format!("{name_str}.openapi.json")); - std::fs::write(&spec_file, &spec_json).map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to write OpenAPI spec file '{}'. Error: {}. Ensure the file path is writable.", spec_file.display(), e)))?; - let spec_path_str = spec_file.display().to_string().replace('\\', "/"); - - // Generate router code (without docs routes, no merge) - let router_code = generate_router_code(&metadata, None, None, None, &[], &[]); - - let result = Ok(quote! { - /// Auto-generated vespera app struct - pub struct #name; - - impl #name { - /// OpenAPI specification as JSON string - pub const OPENAPI_SPEC: &'static str = include_str!(#spec_path_str); - - /// Create the router for this app. - /// Returns `Router<()>` which can be merged into any other router. - pub fn router() -> vespera::axum::Router<()> { - #router_code - } - } - }); - - if let Some(start) = profile_start { - eprintln!( - "[vespera-profile] export_app! macro total: {:?}", - start.elapsed() - ); - crate::schema_macro::print_profile_summary(); - } - - result -} - -#[cfg(test)] -mod tests { - use std::fs; - - use tempfile::TempDir; - - use super::*; - use crate::metadata::RouteMetadata; - - fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> std::path::PathBuf { - let file_path = dir.path().join(filename); - if let Some(parent) = file_path.parent() { - fs::create_dir_all(parent).expect("Failed to create parent directory"); - } - fs::write(&file_path, content).expect("Failed to write temp file"); - file_path - } - - // ========== Tests for generate_and_write_openapi ========== - - #[test] - fn test_generate_and_write_openapi_no_output() { - let processed = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![], - title: None, - version: None, - docs_url: None, - redoc_url: None, - servers: None, - merge: vec![], - }; - let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); - assert!(result.is_ok()); - let (docs_url, redoc_url, spec_json) = result.unwrap(); - assert!(docs_url.is_none()); - assert!(redoc_url.is_none()); - assert!(spec_json.is_none()); - } - - #[test] - fn test_generate_and_write_openapi_docs_only() { - let processed = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![], - title: Some("Test API".to_string()), - version: Some("1.0.0".to_string()), - docs_url: Some("/docs".to_string()), - redoc_url: None, - servers: None, - merge: vec![], - }; - let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); - assert!(result.is_ok()); - let (docs_url, redoc_url, spec_json) = result.unwrap(); - assert!(docs_url.is_some()); - assert_eq!(docs_url.unwrap(), "/docs"); - assert!(spec_json.is_some()); - let json = spec_json.unwrap(); - assert!(json.contains("\"openapi\"")); - assert!(json.contains("Test API")); - assert!(redoc_url.is_none()); - } - - #[test] - fn test_generate_and_write_openapi_redoc_only() { - let processed = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![], - title: None, - version: None, - docs_url: None, - redoc_url: Some("/redoc".to_string()), - servers: None, - merge: vec![], - }; - let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); - assert!(result.is_ok()); - let (docs_url, redoc_url, spec_json) = result.unwrap(); - assert!(docs_url.is_none()); - assert!(redoc_url.is_some()); - assert_eq!(redoc_url.unwrap(), "/redoc"); - assert!(spec_json.is_some()); - } - - #[test] - fn test_generate_and_write_openapi_both_docs() { - let processed = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![], - title: None, - version: None, - docs_url: Some("/docs".to_string()), - redoc_url: Some("/redoc".to_string()), - servers: None, - merge: vec![], - }; - let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); - assert!(result.is_ok()); - let (docs_url, redoc_url, spec_json) = result.unwrap(); - assert!(docs_url.is_some()); - assert!(redoc_url.is_some()); - assert!(spec_json.is_some()); - } - - #[test] - fn test_generate_and_write_openapi_file_output() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let output_path = temp_dir.path().join("test-openapi.json"); - - let processed = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![output_path.to_string_lossy().to_string()], - title: Some("File Test".to_string()), - version: Some("2.0.0".to_string()), - docs_url: None, - redoc_url: None, - servers: None, - merge: vec![], - }; - let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); - assert!(result.is_ok()); - - // Verify file was written - assert!(output_path.exists()); - let content = fs::read_to_string(&output_path).unwrap(); - assert!(content.contains("\"openapi\"")); - assert!(content.contains("File Test")); - assert!(content.contains("2.0.0")); - } - - #[test] - fn test_generate_and_write_openapi_creates_directories() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let output_path = temp_dir.path().join("nested/dir/openapi.json"); - - let processed = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![output_path.to_string_lossy().to_string()], - title: None, - version: None, - docs_url: None, - redoc_url: None, - servers: None, - merge: vec![], - }; - let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); - assert!(result.is_ok()); - - // Verify nested directories and file were created - assert!(output_path.exists()); - } - - // ========== Tests for find_folder_path ========== - // Note: find_folder_path uses CARGO_MANIFEST_DIR which is set during cargo test - - #[test] - fn test_find_folder_path_nonexistent_returns_path() { - // When the constructed path doesn't exist, it falls back to using folder_name directly - let result = find_folder_path("nonexistent_folder_xyz").unwrap(); - // It should return a PathBuf (either from src/nonexistent... or just the folder name) - assert!(result.to_string_lossy().contains("nonexistent_folder_xyz")); - } - - // ========== Tests for find_target_dir ========== - - #[test] - fn test_find_target_dir_no_workspace() { - // Test fallback to manifest dir's target - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let manifest_path = temp_dir.path(); - let result = find_target_dir(manifest_path); - assert_eq!(result, manifest_path.join("target")); - } - - #[test] - fn test_find_target_dir_with_cargo_lock() { - // Test finding target dir with Cargo.lock present - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let manifest_path = temp_dir.path(); - - // Create Cargo.lock (but no [workspace] in Cargo.toml) - fs::write(manifest_path.join("Cargo.lock"), "").expect("Failed to write Cargo.lock"); - - let result = find_target_dir(manifest_path); - // Should use the directory with Cargo.lock - assert_eq!(result, manifest_path.join("target")); - } - - #[test] - fn test_find_target_dir_with_workspace() { - // Test finding workspace root - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let workspace_root = temp_dir.path(); - - // Create a workspace Cargo.toml - fs::write( - workspace_root.join("Cargo.toml"), - "[workspace]\nmembers = [\"crate1\"]", - ) - .expect("Failed to write Cargo.toml"); - - // Create nested crate directory - let crate_dir = workspace_root.join("crate1"); - fs::create_dir(&crate_dir).expect("Failed to create crate dir"); - fs::write(crate_dir.join("Cargo.toml"), "[package]\nname = \"crate1\"") - .expect("Failed to write Cargo.toml"); - - let result = find_target_dir(&crate_dir); - // Should return workspace root's target - assert_eq!(result, workspace_root.join("target")); - } - - #[test] - fn test_find_target_dir_workspace_with_cargo_lock() { - // Test that [workspace] takes priority over Cargo.lock - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let workspace_root = temp_dir.path(); - - // Create workspace Cargo.toml and Cargo.lock - fs::write( - workspace_root.join("Cargo.toml"), - "[workspace]\nmembers = [\"crate1\"]", - ) - .expect("Failed to write Cargo.toml"); - fs::write(workspace_root.join("Cargo.lock"), "").expect("Failed to write Cargo.lock"); - - // Create nested crate - let crate_dir = workspace_root.join("crate1"); - fs::create_dir(&crate_dir).expect("Failed to create crate dir"); - fs::write(crate_dir.join("Cargo.toml"), "[package]\nname = \"crate1\"") - .expect("Failed to write Cargo.toml"); - - let result = find_target_dir(&crate_dir); - assert_eq!(result, workspace_root.join("target")); - } - - #[test] - fn test_find_target_dir_deeply_nested() { - // Test deeply nested crate structure - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let workspace_root = temp_dir.path(); - - // Create workspace - fs::write( - workspace_root.join("Cargo.toml"), - "[workspace]\nmembers = [\"crates/*\"]", - ) - .expect("Failed to write Cargo.toml"); - - // Create deeply nested crate - let deep_crate = workspace_root.join("crates/group/my-crate"); - fs::create_dir_all(&deep_crate).expect("Failed to create nested dirs"); - fs::write(deep_crate.join("Cargo.toml"), "[package]").expect("Failed to write Cargo.toml"); - - let result = find_target_dir(&deep_crate); - assert_eq!(result, workspace_root.join("target")); - } - - // ========== Tests for process_vespera_macro ========== - - #[test] - fn test_process_vespera_macro_folder_not_found() { - let processed = ProcessedVesperaInput { - folder_name: "nonexistent_folder_xyz_123".to_string(), - openapi_file_names: vec![], - title: None, - version: None, - docs_url: None, - redoc_url: None, - servers: None, - merge: vec![], - }; - let result = process_vespera_macro(&processed, &HashMap::new(), &[]); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("route folder") && err.contains("not found")); - } - - #[test] - fn test_process_vespera_macro_collect_metadata_error() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create an invalid route file (will cause parse error but collect_metadata handles it) - create_temp_file(&temp_dir, "invalid.rs", "not valid rust code {{{"); - - let processed = ProcessedVesperaInput { - folder_name: temp_dir.path().to_string_lossy().to_string(), - openapi_file_names: vec![], - title: Some("Test API".to_string()), - version: Some("1.0.0".to_string()), - docs_url: None, - redoc_url: None, - servers: None, - merge: vec![], - }; - - // This exercises the collect_metadata path (which handles parse errors gracefully) - let result = process_vespera_macro(&processed, &HashMap::new(), &[]); - // Result may succeed or fail depending on how collect_metadata handles invalid files - let _ = result; - } - - #[test] - fn test_process_vespera_macro_with_schema_storage() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create an empty file (valid but no routes) - create_temp_file(&temp_dir, "empty.rs", "// empty file\n"); - - let schema_storage = HashMap::from([( - "TestSchema".to_string(), - StructMetadata::new( - "TestSchema".to_string(), - "struct TestSchema { id: i32 }".to_string(), - ), - )]); - - let processed = ProcessedVesperaInput { - folder_name: temp_dir.path().to_string_lossy().to_string(), - openapi_file_names: vec![], - title: None, - version: None, - docs_url: Some("/docs".to_string()), - redoc_url: Some("/redoc".to_string()), - servers: None, - merge: vec![], - }; - - // This exercises the schema_storage extend path - let result = process_vespera_macro(&processed, &schema_storage, &[]); - // We only care about exercising the code path - let _ = result; - } - - #[test] - #[serial_test::serial] - fn test_process_vespera_macro_with_cron_storage() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create src/ subfolder structure to simulate a real project - let src_dir = temp_dir.path().join("src"); - std::fs::create_dir_all(src_dir.join("routes")).expect("create routes dir"); - std::fs::write(src_dir.join("routes").join("health.rs"), "// empty\n") - .expect("write health.rs"); - - // Set CARGO_MANIFEST_DIR so module path derivation works - let old_manifest = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { - std::env::set_var( - "CARGO_MANIFEST_DIR", - temp_dir.path().to_string_lossy().as_ref(), - ); - } - - // Populate CRON_STORAGE with a fake cron entry - { - let mut storage = crate::CRON_STORAGE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - storage.push(crate::cron_impl::StoredCronInfo { - fn_name: "test_cron_job".to_string(), - expression: "0 */5 * * * *".to_string(), - file_path: Some( - src_dir - .join("routes") - .join("health.rs") - .display() - .to_string(), - ), - }); - } - - let processed = ProcessedVesperaInput { - folder_name: src_dir.join("routes").to_string_lossy().to_string(), - openapi_file_names: vec![], - title: None, - version: None, - docs_url: None, - redoc_url: None, - servers: None, - merge: vec![], - }; - - // This exercises the CRON_STORAGE → CronMetadata derivation path - let result = process_vespera_macro(&processed, &HashMap::new(), &[]); - assert!( - result.is_ok(), - "Should succeed with cron storage: {result:?}" - ); - - // Clean up CRON_STORAGE - { - let mut storage = crate::CRON_STORAGE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - storage.retain(|s| s.fn_name != "test_cron_job"); - } - - // Restore CARGO_MANIFEST_DIR - unsafe { - if let Some(val) = old_manifest { - std::env::set_var("CARGO_MANIFEST_DIR", val); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - } - - // ========== Tests for process_export_app ========== - - #[test] - fn test_process_export_app_folder_not_found() { - let name: syn::Ident = syn::parse_quote!(TestApp); - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let result = process_export_app( - &name, - "nonexistent_folder_xyz", - &HashMap::new(), - &temp_dir.path().to_string_lossy(), - &[], - ); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("route folder") && err.contains("not found")); - } - - #[test] - fn test_process_export_app_with_empty_folder() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create an empty file - create_temp_file(&temp_dir, "empty.rs", "// empty\n"); - - let name: syn::Ident = syn::parse_quote!(TestApp); - let folder_path = temp_dir.path().to_string_lossy().to_string(); - - // This exercises collect_metadata and other paths - let result = process_export_app( - &name, - &folder_path, - &HashMap::new(), - &temp_dir.path().to_string_lossy(), - &[], - ); - // We only care about exercising the code path - let _ = result; - } - - #[test] - fn test_process_export_app_with_schema_storage() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create an empty but valid Rust file - create_temp_file(&temp_dir, "mod.rs", "// module file\n"); - - let schema_storage = HashMap::from([( - "AppSchema".to_string(), - StructMetadata::new( - "AppSchema".to_string(), - "struct AppSchema { name: String }".to_string(), - ), - )]); - - let name: syn::Ident = syn::parse_quote!(MyExportedApp); - let folder_path = temp_dir.path().to_string_lossy().to_string(); - - let result = process_export_app( - &name, - &folder_path, - &schema_storage, - &temp_dir.path().to_string_lossy(), - &[], - ); - // Exercises the schema_storage.extend path - let _ = result; - } - - // ========== Tests for generate_and_write_openapi with merge ========== - - #[test] - fn test_generate_and_write_openapi_with_merge_no_manifest_dir() { - // When CARGO_MANIFEST_DIR is not set or merge is empty, it should work normally - let processed = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![], - title: Some("Test".to_string()), - version: None, - docs_url: Some("/docs".to_string()), - redoc_url: None, - servers: None, - merge: vec![syn::parse_quote!(app::TestApp)], // Has merge but no valid manifest dir - }; - let metadata = CollectedMetadata::new(); - // This should still work - merge logic is skipped when CARGO_MANIFEST_DIR lookup fails - let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); - assert!(result.is_ok()); - } - - #[test] - fn test_generate_and_write_openapi_with_merge_and_valid_spec() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create the vespera directory with a spec file - let target_dir = temp_dir.path().join("target").join("vespera"); - fs::create_dir_all(&target_dir).expect("Failed to create target/vespera dir"); - - // Write a valid OpenAPI spec file - let spec_content = - r#"{"openapi":"3.1.0","info":{"title":"Child API","version":"1.0.0"},"paths":{}}"#; - fs::write(target_dir.join("ChildApp.openapi.json"), spec_content) - .expect("Failed to write spec file"); - - // Save and set CARGO_MANIFEST_DIR - let old_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: We're in a single-threaded test context - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let processed = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![], - title: Some("Parent API".to_string()), - version: Some("2.0.0".to_string()), - docs_url: Some("/docs".to_string()), - redoc_url: None, - servers: None, - merge: vec![syn::parse_quote!(child::ChildApp)], - }; - let metadata = CollectedMetadata::new(); - - let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); - - // Restore CARGO_MANIFEST_DIR - if let Some(old_value) = old_manifest_dir { - // SAFETY: We're in a single-threaded test context - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", old_value) }; - } - - assert!(result.is_ok()); - } - - // ========== Tests for find_folder_path ========== - - #[test] - fn test_find_folder_path_absolute_path() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let absolute_path = temp_dir.path().to_string_lossy().to_string(); - - // When given an absolute path that exists, it should return it - let result = find_folder_path(&absolute_path).unwrap(); - // The function tries src/{folder_name} first, then falls back to the folder_name directly - assert!( - result.to_string_lossy().contains(&absolute_path) - || result == Path::new(&absolute_path) - ); - } - - #[test] - fn test_find_folder_path_with_src_folder() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create src/routes directory - let src_routes = temp_dir.path().join("src").join("routes"); - fs::create_dir_all(&src_routes).expect("Failed to create src/routes dir"); - - // Save and set CARGO_MANIFEST_DIR - let old_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: We're in a single-threaded test context - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let result = find_folder_path("routes").unwrap(); - - // Restore CARGO_MANIFEST_DIR - if let Some(old_value) = old_manifest_dir { - // SAFETY: We're in a single-threaded test context - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", old_value) }; - } - - // Should return the src/routes path since it exists - assert!( - result.to_string_lossy().contains("src") && result.to_string_lossy().contains("routes") - ); - } - - // ========== Error path coverage tests ========== - - #[test] - fn test_generate_and_write_openapi_file_write_error() { - // Line 95: fs::write failure when output path is a directory - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create a directory where the output file should be - let output_path = temp_dir.path().join("openapi.json"); - fs::create_dir(&output_path).expect("Failed to create directory"); - - let processed = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![output_path.to_string_lossy().to_string()], - title: Some("Test API".to_string()), - version: Some("1.0.0".to_string()), - docs_url: None, - redoc_url: None, - servers: None, - merge: vec![], - }; - let metadata = CollectedMetadata::new(); - - let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("failed to write file")); - } - - #[test] - fn test_process_export_app_collect_metadata_error() { - // Lines 210-212: collect_metadata returns error for invalid Rust syntax - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create a file with invalid Rust syntax that will cause parse error - create_temp_file(&temp_dir, "invalid.rs", "fn broken( { syntax error"); - - let name: syn::Ident = syn::parse_quote!(TestApp); - let folder_path = temp_dir.path().to_string_lossy().to_string(); - - let result = process_export_app( - &name, - &folder_path, - &HashMap::new(), - &temp_dir.path().to_string_lossy(), - &[], - ); - - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("failed to scan route folder")); - } - - #[test] - fn test_process_export_app_create_dir_error() { - // Lines 232-234: create_dir_all failure when path contains a file - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create an empty valid Rust file - create_temp_file(&temp_dir, "empty.rs", "// empty file\n"); - - // Create target directory but make 'vespera' a file instead of directory - let target_dir = temp_dir.path().join("target"); - fs::create_dir(&target_dir).expect("Failed to create target dir"); - fs::write(target_dir.join("vespera"), "blocking file").expect("Failed to write file"); - - let name: syn::Ident = syn::parse_quote!(TestApp); - let folder_path = temp_dir.path().to_string_lossy().to_string(); - - let result = process_export_app( - &name, - &folder_path, - &HashMap::new(), - &temp_dir.path().to_string_lossy(), - &[], - ); - - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("failed to create build cache directory")); - } - - #[test] - fn test_process_export_app_write_spec_error() { - // Lines 239-241: fs::write failure when spec file path is a directory - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create an empty valid Rust file - create_temp_file(&temp_dir, "empty.rs", "// empty file\n"); - - // Create target/vespera directory and make spec file name a directory - let vespera_dir = temp_dir.path().join("target").join("vespera"); - fs::create_dir_all(&vespera_dir).expect("Failed to create vespera dir"); - // Create a directory where the spec file should be written - fs::create_dir(vespera_dir.join("TestApp.openapi.json")) - .expect("Failed to create blocking dir"); - - let name: syn::Ident = syn::parse_quote!(TestApp); - let folder_path = temp_dir.path().to_string_lossy().to_string(); - - let result = process_export_app( - &name, - &folder_path, - &HashMap::new(), - &temp_dir.path().to_string_lossy(), - &[], - ); - - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("failed to write OpenAPI spec file")); - } - #[test] - fn test_process_vespera_macro_no_openapi_output() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - create_temp_file(&temp_dir, "empty.rs", "// empty route file\n"); - - let processed = ProcessedVesperaInput { - folder_name: temp_dir.path().to_string_lossy().to_string(), - openapi_file_names: vec![], - title: None, - version: None, - docs_url: None, - redoc_url: None, - servers: None, - merge: vec![], - }; - - let result = process_vespera_macro(&processed, &HashMap::new(), &[]); - assert!( - result.is_ok(), - "Should succeed with no openapi output configured" - ); - } - - #[test] - #[serial_test::serial] - fn test_process_vespera_macro_with_profiling() { - let old_profile = std::env::var("VESPERA_PROFILE").ok(); - unsafe { std::env::set_var("VESPERA_PROFILE", "1") }; - - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - create_temp_file(&temp_dir, "empty.rs", "// empty\n"); - - let processed = ProcessedVesperaInput { - folder_name: temp_dir.path().to_string_lossy().to_string(), - openapi_file_names: vec![], - title: None, - version: None, - docs_url: None, - redoc_url: None, - servers: None, - merge: vec![], - }; - - let result = process_vespera_macro(&processed, &HashMap::new(), &[]); - - // Restore - unsafe { - if let Some(val) = old_profile { - std::env::set_var("VESPERA_PROFILE", val); - } else { - std::env::remove_var("VESPERA_PROFILE"); - } - }; - - assert!(result.is_ok()); - } - - #[test] - #[serial_test::serial] - fn test_process_export_app_with_profiling() { - let old_profile = std::env::var("VESPERA_PROFILE").ok(); - unsafe { std::env::set_var("VESPERA_PROFILE", "1") }; - - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - create_temp_file(&temp_dir, "empty.rs", "// empty\n"); - - let name: syn::Ident = syn::parse_quote!(TestProfileApp); - let folder_path = temp_dir.path().to_string_lossy().to_string(); - - let result = process_export_app( - &name, - &folder_path, - &HashMap::new(), - &temp_dir.path().to_string_lossy(), - &[], - ); - - // Restore - unsafe { - if let Some(val) = old_profile { - std::env::set_var("VESPERA_PROFILE", val); - } else { - std::env::remove_var("VESPERA_PROFILE"); - } - }; - - // Exercise the code path - let _ = result; - } - - // ========== Tests for merge_route_storage_data ========== - - #[test] - fn test_merge_route_storage_empty_storage() { - let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "get".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "routes".to_string(), - file_path: "routes/users.rs".to_string(), - signature: "pub async fn get_users() -> Json>".to_string(), - error_status: None, - tags: None, - description: None, - }); - - merge_route_storage_data(&mut metadata, &[]); - // No changes when storage is empty - assert!(metadata.routes[0].tags.is_none()); - assert!(metadata.routes[0].description.is_none()); - assert!(metadata.routes[0].error_status.is_none()); - } - - #[test] - fn test_merge_route_storage_matching_route() { - let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "get".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "routes".to_string(), - file_path: "routes/users.rs".to_string(), - signature: "pub async fn get_users() -> Json>".to_string(), - error_status: None, - tags: None, - description: None, - }); - - let storage = vec![StoredRouteInfo { - fn_name: "get_users".to_string(), - method: Some("get".to_string()), - custom_path: None, - error_status: Some(vec![400, 404]), - tags: Some(vec!["users".to_string()]), - description: Some("List all users".to_string()), - fn_item_str: String::new(), - file_path: None, - }]; - - merge_route_storage_data(&mut metadata, &storage); - assert_eq!(metadata.routes[0].tags, Some(vec!["users".to_string()])); - assert_eq!( - metadata.routes[0].description, - Some("List all users".to_string()) - ); - assert_eq!(metadata.routes[0].error_status, Some(vec![400, 404])); - } - - #[test] - fn test_merge_route_storage_no_match() { - let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "get".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "routes".to_string(), - file_path: "routes/users.rs".to_string(), - signature: String::new(), - error_status: None, - tags: None, - description: None, - }); - - let storage = vec![StoredRouteInfo { - fn_name: "create_user".to_string(), - method: Some("post".to_string()), - custom_path: None, - error_status: Some(vec![400]), - tags: Some(vec!["users".to_string()]), - description: None, - fn_item_str: String::new(), - file_path: None, - }]; - - merge_route_storage_data(&mut metadata, &storage); - // No match — fields unchanged - assert!(metadata.routes[0].tags.is_none()); - assert!(metadata.routes[0].error_status.is_none()); - } - - #[test] - fn test_merge_route_storage_ambiguous_skipped() { - let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "get".to_string(), - path: "/users".to_string(), - function_name: "handler".to_string(), - module_path: "routes".to_string(), - file_path: "routes/users.rs".to_string(), - signature: String::new(), - error_status: None, - tags: None, - description: None, - }); - - // Two StoredRouteInfo with same fn_name — ambiguous - let storage = vec![ - StoredRouteInfo { - fn_name: "handler".to_string(), - method: Some("get".to_string()), - custom_path: None, - error_status: None, - tags: Some(vec!["file-a".to_string()]), - description: None, - fn_item_str: String::new(), - file_path: None, - }, - StoredRouteInfo { - fn_name: "handler".to_string(), - method: Some("post".to_string()), - custom_path: None, - error_status: None, - tags: Some(vec!["file-b".to_string()]), - description: None, - fn_item_str: String::new(), - file_path: None, - }, - ]; - - merge_route_storage_data(&mut metadata, &storage); - // Ambiguous match — no merge - assert!(metadata.routes[0].tags.is_none()); - } - - #[test] - fn test_merge_route_storage_preserves_existing() { - let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "get".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "routes".to_string(), - file_path: "routes/users.rs".to_string(), - signature: String::new(), - error_status: Some(vec![500]), - tags: Some(vec!["existing-tag".to_string()]), - description: Some("Existing description".to_string()), - }); - - let storage = vec![StoredRouteInfo { - fn_name: "get_users".to_string(), - method: Some("get".to_string()), - custom_path: None, - error_status: Some(vec![400, 404]), - tags: Some(vec!["new-tag".to_string()]), - description: Some("New description".to_string()), - fn_item_str: String::new(), - file_path: None, - }]; - - merge_route_storage_data(&mut metadata, &storage); - // ROUTE_STORAGE values override when they have explicit values - assert_eq!(metadata.routes[0].tags, Some(vec!["new-tag".to_string()])); - assert_eq!( - metadata.routes[0].description, - Some("New description".to_string()) - ); - assert_eq!(metadata.routes[0].error_status, Some(vec![400, 404])); - } - - #[test] - fn test_merge_route_storage_partial_fields() { - let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "get".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "routes".to_string(), - file_path: "routes/users.rs".to_string(), - signature: String::new(), - error_status: None, - tags: Some(vec!["from-collector".to_string()]), - description: Some("From doc comment".to_string()), - }); - - // StoredRouteInfo with only error_status (tags/description are None) - let storage = vec![StoredRouteInfo { - fn_name: "get_users".to_string(), - method: Some("get".to_string()), - custom_path: None, - error_status: Some(vec![400]), - tags: None, - description: None, - fn_item_str: String::new(), - file_path: None, - }]; - - merge_route_storage_data(&mut metadata, &storage); - // Only error_status should be set; tags and description preserved from collector - assert_eq!( - metadata.routes[0].tags, - Some(vec!["from-collector".to_string()]) - ); - assert_eq!( - metadata.routes[0].description, - Some("From doc comment".to_string()) - ); - assert_eq!(metadata.routes[0].error_status, Some(vec![400])); - } - - #[test] - fn test_compute_config_hash_with_servers() { - // Exercises lines 92-96: servers loop in compute_config_hash - let processed_no_servers = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![], - title: None, - version: None, - docs_url: None, - redoc_url: None, - servers: None, - merge: vec![], - }; - - let processed_with_servers = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![], - title: None, - version: None, - docs_url: None, - redoc_url: None, - servers: Some(vec![ - vespera_core::openapi::Server { - url: "https://api.example.com".to_string(), - description: None, - variables: None, - }, - vespera_core::openapi::Server { - url: "http://localhost:3000".to_string(), - description: None, - variables: None, - }, - ]), - merge: vec![], - }; - - let hash_no_servers = compute_config_hash(&processed_no_servers); - let hash_with_servers = compute_config_hash(&processed_with_servers); - - // Different servers should produce different hashes - assert_ne!( - hash_no_servers, hash_with_servers, - "Servers should affect config hash" - ); - } - - #[test] - fn test_compute_config_hash_with_merge() { - // Exercises lines 97-99: merge loop in compute_config_hash - let processed_no_merge = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![], - title: None, - version: None, - docs_url: None, - redoc_url: None, - servers: None, - merge: vec![], - }; - - let processed_with_merge = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![], - title: None, - version: None, - docs_url: None, - redoc_url: None, - servers: None, - merge: vec![syn::parse_quote!(app::TestApp)], - }; - - let hash_no_merge = compute_config_hash(&processed_no_merge); - let hash_with_merge = compute_config_hash(&processed_with_merge); - - assert_ne!( - hash_no_merge, hash_with_merge, - "Merge paths should affect config hash" - ); - } - - #[test] - fn test_ensure_openapi_files_from_cache_none_spec() { - // Exercises lines 266-267: early return when spec_pretty is None - let result = ensure_openapi_files_from_cache(&["dummy.json".to_string()], None); - assert!(result.is_ok()); - } - - #[test] - fn test_ensure_openapi_files_from_cache_writes_file() { - // Exercises lines 269-276: write new file - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let output_path = temp_dir.path().join("api.json"); - let spec = r#"{"openapi":"3.1.0"}"#; - - let result = ensure_openapi_files_from_cache( - &[output_path.to_string_lossy().to_string()], - Some(spec), - ); - assert!(result.is_ok()); - assert_eq!(fs::read_to_string(&output_path).unwrap(), spec); - } - - #[test] - fn test_ensure_openapi_files_from_cache_skip_unchanged() { - // Exercises line 271-272: should_write is false when content matches - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let output_path = temp_dir.path().join("api.json"); - let spec = r#"{"openapi":"3.1.0"}"#; - - // Write file first with same content - fs::write(&output_path, spec).unwrap(); - - let result = ensure_openapi_files_from_cache( - &[output_path.to_string_lossy().to_string()], - Some(spec), - ); - assert!(result.is_ok()); - // File should still contain same content (no unnecessary write) - assert_eq!(fs::read_to_string(&output_path).unwrap(), spec); - } - - #[test] - fn test_ensure_openapi_files_from_cache_creates_parent_dirs() { - // Exercises lines 273-274: create parent directories - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let output_path = temp_dir.path().join("nested").join("dir").join("api.json"); - let spec = r#"{"openapi":"3.1.0"}"#; - - let result = ensure_openapi_files_from_cache( - &[output_path.to_string_lossy().to_string()], - Some(spec), - ); - assert!(result.is_ok()); - assert!(output_path.exists()); - assert_eq!(fs::read_to_string(&output_path).unwrap(), spec); - } - - #[test] - fn test_ensure_openapi_files_from_cache_write_error() { - // Exercises line 276: write failure - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let output_path = temp_dir.path().join("api.json"); - - // Create a directory where the file should be -> write will fail - fs::create_dir(&output_path).unwrap(); - - let result = ensure_openapi_files_from_cache( - &[output_path.to_string_lossy().to_string()], - Some("spec"), - ); - assert!(result.is_err()); - } - - #[test] - fn test_ensure_openapi_files_from_cache_multiple_files() { - // Exercises the loop with multiple file names (line 269) - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let path1 = temp_dir.path().join("api1.json"); - let path2 = temp_dir.path().join("api2.json"); - let spec = r#"{"openapi":"3.1.0"}"#; - - let result = ensure_openapi_files_from_cache( - &[ - path1.to_string_lossy().to_string(), - path2.to_string_lossy().to_string(), - ], - Some(spec), - ); - assert!(result.is_ok()); - assert_eq!(fs::read_to_string(&path1).unwrap(), spec); - assert_eq!(fs::read_to_string(&path2).unwrap(), spec); - } - - #[test] - #[serial_test::serial] - fn test_process_vespera_macro_cache_hit() { - // Exercises lines 320-324, 327, 329: the cache_hit branch in process_vespera_macro. - // First call populates the cache, second call hits it. - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - create_temp_file( - &temp_dir, - "users.rs", - "pub async fn list_users() -> String { \"users\".to_string() }\n", - ); - - let folder_path = temp_dir.path().to_string_lossy().to_string(); - let openapi_path = temp_dir.path().join("openapi.json"); - - // Set CARGO_MANIFEST_DIR so cache path resolves to temp_dir/target/vespera/ - let old_manifest = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let processed = ProcessedVesperaInput { - folder_name: folder_path.clone(), - openapi_file_names: vec![openapi_path.to_string_lossy().to_string()], - title: Some("Test API".to_string()), - version: Some("1.0.0".to_string()), - docs_url: Some("/docs".to_string()), - redoc_url: None, - servers: None, - merge: vec![], - }; - - // First call: cache MISS — scans files, generates spec, writes cache - let result1 = process_vespera_macro(&processed, &HashMap::new(), &[]); - assert!( - result1.is_ok(), - "First call (cache miss) should succeed: {:?}", - result1.err() - ); - assert!( - openapi_path.exists(), - "openapi.json should be written on first call" - ); - - // Second call: cache HIT — exercises lines 320-324, 327, 329 - let result2 = process_vespera_macro(&processed, &HashMap::new(), &[]); - assert!( - result2.is_ok(), - "Second call (cache hit) should succeed: {:?}", - result2.err() - ); - - // Restore CARGO_MANIFEST_DIR - unsafe { - if let Some(val) = old_manifest { - std::env::set_var("CARGO_MANIFEST_DIR", val); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - }; - } -} +//! Public orchestrators and helper functions are re-exported from child +//! modules to preserve `crate::vespera_impl::...` call paths. + +mod cache; +mod openapi_io; +mod orchestrator; +mod path_utils; +mod route_merge; + +#[allow(unused_imports)] +pub use openapi_io::{DocsInfo, ensure_openapi_files_from_cache, generate_and_write_openapi}; +pub use orchestrator::{process_export_app, process_vespera_macro}; +#[allow(unused_imports)] +pub use path_utils::{find_folder_path, find_target_dir}; diff --git a/crates/vespera_macro/src/vespera_impl/cache.rs b/crates/vespera_macro/src/vespera_impl/cache.rs new file mode 100644 index 00000000..aa0cd3ab --- /dev/null +++ b/crates/vespera_macro/src/vespera_impl/cache.rs @@ -0,0 +1,327 @@ +use std::{ + collections::HashMap, + hash::{Hash, Hasher}, + path::Path, +}; + +use quote::quote; +use serde::{Deserialize, Serialize}; + +use crate::{ + metadata::{CollectedMetadata, StructMetadata}, + router_codegen::ProcessedVesperaInput, +}; + +use super::path_utils::{current_crate_tag, find_target_dir}; + +/// Current cache format. Bump when the on-disk layout changes — +/// old caches deserialize with `cache_format: 0` (serde default) and +/// are treated as a miss. +pub(super) const CACHE_FORMAT: u32 = 1; + +/// Cache for avoiding redundant route scanning and OpenAPI generation. +/// Persisted to `target/vespera/routes.cache` across builds. +/// +/// The spec JSON strings themselves live in **sidecar files** (the +/// `include_str!` embed file and the pretty sidecar) — the cache only +/// stores their content hashes. Embedding them inline as JSON strings +/// doubled the cache size via escaping and dominated warm-rebuild +/// `read_cache` time. +#[derive(Serialize, Deserialize)] +pub(super) struct VesperaCache { + /// On-disk layout version — see [`CACHE_FORMAT`]. + #[serde(default)] + pub(super) cache_format: u32, + /// Macro crate version — invalidates cache when macro code changes + #[serde(default)] + pub(super) macro_version: String, + /// In-repo macro source fingerprint — invalidates cache when the + /// macro source itself changes during vespera development (the + /// version alone only changes per release). `0` for downstream + /// users. See [`compute_macro_dev_fingerprint`]. + #[serde(default)] + pub(super) macro_dev_fingerprint: u64, + /// File path → modification time (secs since UNIX_EPOCH) + pub(super) file_fingerprints: HashMap, + /// Hash of SCHEMA_STORAGE contents + pub(super) schema_hash: u64, + /// Hash of OpenAPI config (title, version, servers, docs_url, etc.) + pub(super) config_hash: u64, + /// Cached route/struct metadata + pub(super) metadata: CollectedMetadata, + /// Content hash of the compact spec in the embed sidecar file + /// (`vespera_spec-.json`). `None` if docs disabled. + #[serde(default)] + pub(super) spec_json_hash: Option, + /// Content hash of the pretty spec in the pretty sidecar file + /// (`openapi_pretty-.json`). `None` if no openapi file configured. + #[serde(default)] + pub(super) spec_pretty_hash: Option, +} + +/// Deterministic content hash for sidecar spec validation. +pub(super) fn hash_str(s: &str) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + s.hash(&mut hasher); + hasher.finish() +} + +/// Compute a deterministic hash of SCHEMA_STORAGE contents. +pub(super) fn compute_schema_hash(schema_storage: &HashMap) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + let mut keys: Vec<&String> = schema_storage.keys().collect(); + keys.sort(); + for key in keys { + key.hash(&mut hasher); + let meta = &schema_storage[key]; + meta.name.hash(&mut hasher); + meta.definition.hash(&mut hasher); + meta.include_in_openapi.hash(&mut hasher); + } + hasher.finish() +} + +/// Compute a deterministic hash of OpenAPI config fields. +pub(super) fn compute_config_hash(processed: &ProcessedVesperaInput) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + processed.title.hash(&mut hasher); + processed.version.hash(&mut hasher); + processed.docs_url.hash(&mut hasher); + processed.redoc_url.hash(&mut hasher); + processed.openapi_file_names.hash(&mut hasher); + if let Some(ref servers) = processed.servers { + for s in servers { + s.url.hash(&mut hasher); + } + } + for merge_path in &processed.merge { + quote!(#merge_path).to_string().hash(&mut hasher); + } + hasher.finish() +} + +/// Get the path to this crate's routes cache file. +pub(super) fn get_cache_path() -> std::path::PathBuf { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default(); + let manifest_path = Path::new(&manifest_dir); + find_target_dir(manifest_path) + .join("vespera") + .join(format!("routes-{}.cache", current_crate_tag())) +} + +/// Fingerprint of the vespera_macro **source tree itself**, for cache +/// invalidation while developing the macro in this repository. +/// +/// `macro_version` only changes per release, so editing macro code +/// in-repo would otherwise keep serving the previous build's cached +/// spec. When `{workspace_root}/crates/vespera_macro/src` exists +/// (i.e. the consuming crate lives inside the vespera repo), hash +/// every `.rs` mtime in it; for downstream users the directory is +/// absent and this is a single failed `stat` (returns 0). +pub(super) fn compute_macro_dev_fingerprint() -> u64 { + // Memoized per proc-macro process: macro source mtimes cannot change + // the dll that is currently executing, so one scan per process is + // exactly as precise as one scan per invocation. (A fresh cargo + // build of vespera_macro loads a fresh dll → fresh process state.) + static MEMO: std::sync::OnceLock = std::sync::OnceLock::new(); + *MEMO.get_or_init(compute_macro_dev_fingerprint_uncached) +} + +fn compute_macro_dev_fingerprint_uncached() -> u64 { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default(); + let target_dir = find_target_dir(Path::new(&manifest_dir)); + let Some(workspace_root) = target_dir.parent() else { + return 0; + }; + let macro_src = workspace_root + .join("crates") + .join("vespera_macro") + .join("src"); + if !macro_src.is_dir() { + return 0; + } + let mut entries: Vec<(String, u64)> = Vec::new(); + collect_rs_mtimes(¯o_src, &mut entries); + entries.sort(); + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + for (path, mtime) in &entries { + path.hash(&mut hasher); + mtime.hash(&mut hasher); + } + hasher.finish() +} + +/// Recursively collect `(path, mtime)` pairs for `.rs` files. +/// +/// Uses `DirEntry::file_type()` / `DirEntry::metadata()` rather than +/// `Path::is_dir()` / `fs::metadata(&path)`: both `DirEntry` accessors +/// are carried by the directory scan (free on Windows + most Unix), so +/// the dir/file split costs no extra `stat` syscall per entry — only +/// the `.rs` files we actually fingerprint pay for their mtime. +fn collect_rs_mtimes(dir: &Path, out: &mut Vec<(String, u64)>) { + let Ok(read_dir) = std::fs::read_dir(dir) else { + return; + }; + for entry in read_dir.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + let path = entry.path(); + if file_type.is_dir() { + collect_rs_mtimes(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + let mtime = entry.metadata().and_then(|m| m.modified()).map_or(0, |t| { + t.duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + }); + out.push((path.display().to_string(), mtime)); + } + } +} + +/// Try to read and deserialize a cache file. Returns None on any failure. +pub(super) fn read_cache(cache_path: &Path) -> Option { + let content = std::fs::read_to_string(cache_path).ok()?; + serde_json::from_str(&content).ok() +} + +/// Write cache to disk. Failures are silently ignored (cache is best-effort). +pub(super) fn write_cache(cache_path: &Path, cache: &VesperaCache) { + if let Some(parent) = cache_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string(cache) { + let _ = std::fs::write(cache_path, json); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_compute_config_hash_with_servers() { + // Exercises lines 92-96: servers loop in compute_config_hash + let processed_no_servers = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![], + title: None, + version: None, + docs_url: None, + redoc_url: None, + servers: None, + merge: vec![], + }; + + let processed_with_servers = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![], + title: None, + version: None, + docs_url: None, + redoc_url: None, + servers: Some(vec![ + vespera_core::openapi::Server { + url: "https://api.example.com".to_string(), + description: None, + variables: None, + }, + vespera_core::openapi::Server { + url: "http://localhost:3000".to_string(), + description: None, + variables: None, + }, + ]), + merge: vec![], + }; + + let hash_no_servers = compute_config_hash(&processed_no_servers); + let hash_with_servers = compute_config_hash(&processed_with_servers); + + // Different servers should produce different hashes + assert_ne!( + hash_no_servers, hash_with_servers, + "Servers should affect config hash" + ); + } + + #[test] + fn test_compute_config_hash_with_merge() { + // Exercises lines 97-99: merge loop in compute_config_hash + let processed_no_merge = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![], + title: None, + version: None, + docs_url: None, + redoc_url: None, + servers: None, + merge: vec![], + }; + + let processed_with_merge = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![], + title: None, + version: None, + docs_url: None, + redoc_url: None, + servers: None, + merge: vec![syn::parse_quote!(app::TestApp)], + }; + + let hash_no_merge = compute_config_hash(&processed_no_merge); + let hash_with_merge = compute_config_hash(&processed_with_merge); + + assert_ne!( + hash_no_merge, hash_with_merge, + "Merge paths should affect config hash" + ); + } + + #[test] + fn test_read_cache_corrupt_file_returns_none() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("routes.cache"); + std::fs::write(&path, "{not valid json").unwrap(); + assert!(read_cache(&path).is_none(), "corrupt cache must be a miss"); + } + + #[test] + fn test_read_cache_missing_file_returns_none() { + let dir = tempfile::TempDir::new().unwrap(); + assert!(read_cache(&dir.path().join("nope.cache")).is_none()); + } + + #[test] + fn test_old_format_cache_deserializes_with_format_zero() { + // A pre-sidecar cache (inline spec strings, no cache_format + // field) must still parse — with cache_format defaulting to 0 + // so the orchestrator's `== CACHE_FORMAT` check misses. + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("routes.cache"); + let old_format = serde_json::json!({ + "macro_version": "0.1.0", + "macro_dev_fingerprint": 1u64, + "file_fingerprints": {}, + "schema_hash": 2u64, + "config_hash": 3u64, + "metadata": { "routes": [], "structs": [] }, + "spec_json": "{\"openapi\":\"3.1.0\"}", + "spec_pretty": "{\n \"openapi\": \"3.1.0\"\n}" + }); + std::fs::write(&path, old_format.to_string()).unwrap(); + let cache = read_cache(&path).expect("old format must still deserialize"); + assert_eq!(cache.cache_format, 0, "missing field defaults to 0"); + assert_ne!(cache.cache_format, CACHE_FORMAT, "format check must miss"); + assert!(cache.spec_json_hash.is_none()); + assert!(cache.spec_pretty_hash.is_none()); + } + + #[test] + fn test_hash_str_deterministic_and_content_sensitive() { + assert_eq!(hash_str("abc"), hash_str("abc")); + assert_ne!(hash_str("abc"), hash_str("abd")); + } +} diff --git a/crates/vespera_macro/src/vespera_impl/openapi_io.rs b/crates/vespera_macro/src/vespera_impl/openapi_io.rs new file mode 100644 index 00000000..4edd041d --- /dev/null +++ b/crates/vespera_macro/src/vespera_impl/openapi_io.rs @@ -0,0 +1,590 @@ +use std::{collections::HashMap, path::Path}; + +use crate::{ + error::{MacroResult, err_call_site}, + metadata::CollectedMetadata, + openapi_generator::generate_openapi_doc_with_metadata, + route_impl::StoredRouteInfo, + router_codegen::ProcessedVesperaInput, +}; +use proc_macro2::Span; + +use super::path_utils::{current_crate_tag, find_target_dir}; + +/// Docs info tuple type alias for cleaner signatures +pub type DocsInfo = (Option, Option, Option); + +/// Generate `OpenAPI` JSON and write to files, returning docs info +pub fn generate_and_write_openapi( + input: &ProcessedVesperaInput, + metadata: &CollectedMetadata, + file_asts: HashMap, + route_storage: &[StoredRouteInfo], +) -> MacroResult { + if input.openapi_file_names.is_empty() && input.docs_url.is_none() && input.redoc_url.is_none() + { + return Ok((None, None, None)); + } + + let mut openapi_doc = generate_openapi_doc_with_metadata( + input.title.clone(), + input.version.clone(), + input.servers.clone(), + metadata, + Some(file_asts), + route_storage, + ); + + // Merge specs from child apps at compile time + if !input.merge.is_empty() + && let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") + { + let manifest_path = Path::new(&manifest_dir); + let target_dir = find_target_dir(manifest_path); + let vespera_dir = target_dir.join("vespera"); + + for merge_path in &input.merge { + // Extract the struct name (last segment, e.g., "ThirdApp" from "third::ThirdApp") + if let Some(last_segment) = merge_path.segments.last() { + let struct_name = last_segment.ident.to_string(); + let spec_file = vespera_dir.join(format!("{struct_name}.openapi.json")); + + if let Ok(spec_content) = std::fs::read_to_string(&spec_file) + && let Ok(child_spec) = + serde_json::from_str::(&spec_content) + { + openapi_doc.merge(child_spec); + } + } + } + } + + // NOTE on F-01: an earlier audit suggested serialising the + // `OpenApi` document once into `serde_json::Value` and emitting + // pretty + compact from the cached `Value`. We deliberately do + // **not** do that here. Going through `Value` re-orders every + // object's keys alphabetically (because the default + // `serde_json::Map` is `BTreeMap`-backed), which silently changes + // the field order in every user-visible `openapi.json` file. The + // marginal build-time saving is not worth churning the output of a + // file users diff in CI. Keep two direct serialisations. + // + // Pretty-print for user-visible files. + if !input.openapi_file_names.is_empty() { + let json_pretty = serde_json::to_string_pretty(&openapi_doc).map_err(|e| err_call_site(format!("OpenAPI generation: failed to serialize document to JSON. Error: {e}. Check that all schema types are serializable.")))?; + for openapi_file_name in &input.openapi_file_names { + let file_path = Path::new(openapi_file_name); + if let Some(parent) = file_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| err_call_site(format!("OpenAPI output: failed to create directory '{}'. Error: {}. Ensure the path is valid and writable.", parent.display(), e)))?; + } + let should_write = + std::fs::read_to_string(file_path).map_or(true, |existing| existing != json_pretty); + if should_write { + std::fs::write(file_path, &json_pretty).map_err(|e| err_call_site(format!("OpenAPI output: failed to write file '{openapi_file_name}'. Error: {e}. Ensure the file path is writable.")))?; + } + } + } + + // Compact JSON for embedding (smaller binary, faster downstream compilation). + let spec_json = if input.docs_url.is_some() || input.redoc_url.is_some() { + Some(serde_json::to_string(&openapi_doc).map_err(|e| err_call_site(format!("OpenAPI generation: failed to serialize document to JSON. Error: {e}. Check that all schema types are serializable.")))?) + } else { + None + }; + + Ok((input.docs_url.clone(), input.redoc_url.clone(), spec_json)) +} + +/// Write cached OpenAPI spec to output files if they are stale or missing. +pub fn ensure_openapi_files_from_cache( + openapi_file_names: &[String], + spec_pretty: Option<&str>, +) -> syn::Result<()> { + let Some(pretty) = spec_pretty else { + return Ok(()); + }; + for openapi_file_name in openapi_file_names { + let file_path = Path::new(openapi_file_name); + let should_write = + std::fs::read_to_string(file_path).map_or(true, |existing| existing != *pretty); + if should_write { + if let Some(parent) = file_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + syn::Error::new( + Span::call_site(), + format!( + "OpenAPI output: failed to create directory '{}': {}", + parent.display(), + e + ), + ) + })?; + } + std::fs::write(file_path, pretty).map_err(|e| { + syn::Error::new( + Span::call_site(), + format!("OpenAPI output: failed to write file '{openapi_file_name}': {e}"), + ) + })?; + } + } + Ok(()) +} + +/// Path of the compact-spec embed sidecar (`include_str!` target). +/// +/// The file name is **namespaced per crate**: two workspace members +/// both using `vespera!` compile in parallel under the same shared +/// `target/vespera/` directory — with a single shared file name, crate +/// A's `include_str!` could read the spec crate B just wrote. +pub(super) fn embed_spec_path() -> std::path::PathBuf { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default(); + find_target_dir(Path::new(&manifest_dir)) + .join("vespera") + .join(format!("vespera_spec-{}.json", current_crate_tag())) +} + +/// Path of the pretty-spec sidecar (warm-rebuild source for +/// `openapi.json` recovery — see `ensure_openapi_files_from_cache`). +pub(super) fn pretty_sidecar_path() -> std::path::PathBuf { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default(); + find_target_dir(Path::new(&manifest_dir)) + .join("vespera") + .join(format!("openapi_pretty-{}.json", current_crate_tag())) +} + +/// Build the `include_str!` tokens pointing at the embed sidecar. +fn embed_tokens(spec_file: &Path) -> proc_macro2::TokenStream { + let path_str = spec_file.display().to_string().replace('\\', "/"); + quote::quote! { include_str!(#path_str) } +} + +/// Hash-validated sidecar specs loaded on a warm cache hit. +pub(super) struct SidecarSpecs { + /// Pretty spec content (for `openapi.json` recovery); `None` when + /// no openapi file is configured. + pub(super) pretty: Option, + /// `include_str!` tokens for the embed sidecar; `None` when docs + /// are disabled. + pub(super) spec_tokens: Option, +} + +/// Load and hash-validate the sidecar spec files on a warm cache hit. +/// +/// Returns `None` when any expected sidecar is missing or fails its +/// content-hash check — the caller must then treat the cache as a miss +/// (a full regeneration rewrites both sidecars, so corruption +/// self-heals on the next build). +pub(super) fn load_validated_sidecar_specs( + spec_json_hash: Option, + spec_pretty_hash: Option, +) -> Option { + let spec_tokens = match spec_json_hash { + None => None, + Some(expected) => { + let path = embed_spec_path(); + let content = std::fs::read_to_string(&path).ok()?; + if super::cache::hash_str(&content) != expected { + return None; + } + Some(embed_tokens(&path)) + } + }; + let pretty = match spec_pretty_hash { + None => None, + Some(expected) => { + let content = std::fs::read_to_string(pretty_sidecar_path()).ok()?; + if super::cache::hash_str(&content) != expected { + return None; + } + Some(content) + } + }; + Some(SidecarSpecs { + pretty, + spec_tokens, + }) +} + +/// Write the pretty-spec sidecar (write-if-differs). Best-effort like +/// the cache itself: failures only cost a future cache miss. +pub(super) fn write_pretty_sidecar(spec_pretty: Option<&str>) { + let Some(pretty) = spec_pretty else { + return; + }; + let path = pretty_sidecar_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let should_write = std::fs::read_to_string(&path).map_or(true, |existing| existing != pretty); + if should_write { + let _ = std::fs::write(&path, pretty); + } +} + +/// Write compact spec JSON to target dir for `include_str!` embedding. +pub(super) fn write_spec_for_embedding( + spec_json: Option, +) -> syn::Result> { + let Some(json) = spec_json else { + return Ok(None); + }; + let spec_file = embed_spec_path(); + if let Some(parent) = spec_file.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + syn::Error::new( + Span::call_site(), + format!( + "vespera! macro: failed to create directory '{}': {}", + parent.display(), + e + ), + ) + })?; + } + let should_write = + std::fs::read_to_string(&spec_file).map_or(true, |existing| existing != json); + if should_write { + std::fs::write(&spec_file, &json).map_err(|e| { + syn::Error::new( + Span::call_site(), + format!( + "vespera! macro: failed to write spec file '{}': {}", + spec_file.display(), + e + ), + ) + })?; + } + Ok(Some(embed_tokens(&spec_file))) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::*; + + #[test] + fn test_generate_and_write_openapi_no_output() { + let processed = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![], + title: None, + version: None, + docs_url: None, + redoc_url: None, + servers: None, + merge: vec![], + }; + let metadata = CollectedMetadata::new(); + let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); + assert!(result.is_ok()); + let (docs_url, redoc_url, spec_json) = result.unwrap(); + assert!(docs_url.is_none()); + assert!(redoc_url.is_none()); + assert!(spec_json.is_none()); + } + + #[test] + fn test_generate_and_write_openapi_docs_only() { + let processed = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![], + title: Some("Test API".to_string()), + version: Some("1.0.0".to_string()), + docs_url: Some("/docs".to_string()), + redoc_url: None, + servers: None, + merge: vec![], + }; + let metadata = CollectedMetadata::new(); + let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); + assert!(result.is_ok()); + let (docs_url, redoc_url, spec_json) = result.unwrap(); + assert!(docs_url.is_some()); + assert_eq!(docs_url.unwrap(), "/docs"); + assert!(spec_json.is_some()); + let json = spec_json.unwrap(); + assert!(json.contains("\"openapi\"")); + assert!(json.contains("Test API")); + assert!(redoc_url.is_none()); + } + + #[test] + fn test_generate_and_write_openapi_redoc_only() { + let processed = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![], + title: None, + version: None, + docs_url: None, + redoc_url: Some("/redoc".to_string()), + servers: None, + merge: vec![], + }; + let metadata = CollectedMetadata::new(); + let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); + assert!(result.is_ok()); + let (docs_url, redoc_url, spec_json) = result.unwrap(); + assert!(docs_url.is_none()); + assert!(redoc_url.is_some()); + assert_eq!(redoc_url.unwrap(), "/redoc"); + assert!(spec_json.is_some()); + } + + #[test] + fn test_generate_and_write_openapi_both_docs() { + let processed = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![], + title: None, + version: None, + docs_url: Some("/docs".to_string()), + redoc_url: Some("/redoc".to_string()), + servers: None, + merge: vec![], + }; + let metadata = CollectedMetadata::new(); + let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); + assert!(result.is_ok()); + let (docs_url, redoc_url, spec_json) = result.unwrap(); + assert!(docs_url.is_some()); + assert!(redoc_url.is_some()); + assert!(spec_json.is_some()); + } + + #[test] + fn test_generate_and_write_openapi_file_output() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let output_path = temp_dir.path().join("test-openapi.json"); + + let processed = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![output_path.to_string_lossy().to_string()], + title: Some("File Test".to_string()), + version: Some("2.0.0".to_string()), + docs_url: None, + redoc_url: None, + servers: None, + merge: vec![], + }; + let metadata = CollectedMetadata::new(); + let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); + assert!(result.is_ok()); + + // Verify file was written + assert!(output_path.exists()); + let content = fs::read_to_string(&output_path).unwrap(); + assert!(content.contains("\"openapi\"")); + assert!(content.contains("File Test")); + assert!(content.contains("2.0.0")); + } + + #[test] + fn test_generate_and_write_openapi_creates_directories() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let output_path = temp_dir.path().join("nested/dir/openapi.json"); + + let processed = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![output_path.to_string_lossy().to_string()], + title: None, + version: None, + docs_url: None, + redoc_url: None, + servers: None, + merge: vec![], + }; + let metadata = CollectedMetadata::new(); + let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); + assert!(result.is_ok()); + + // Verify nested directories and file were created + assert!(output_path.exists()); + } + + #[test] + fn test_generate_and_write_openapi_with_merge_no_manifest_dir() { + // When CARGO_MANIFEST_DIR is not set or merge is empty, it should work normally + let processed = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![], + title: Some("Test".to_string()), + version: None, + docs_url: Some("/docs".to_string()), + redoc_url: None, + servers: None, + merge: vec![syn::parse_quote!(app::TestApp)], // Has merge but no valid manifest dir + }; + let metadata = CollectedMetadata::new(); + // This should still work - merge logic is skipped when CARGO_MANIFEST_DIR lookup fails + let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); + assert!(result.is_ok()); + } + + #[serial_test::serial] + #[test] + fn test_generate_and_write_openapi_with_merge_and_valid_spec() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create the vespera directory with a spec file + let target_dir = temp_dir.path().join("target").join("vespera"); + fs::create_dir_all(&target_dir).expect("Failed to create target/vespera dir"); + + // Write a valid OpenAPI spec file + let spec_content = + r#"{"openapi":"3.1.0","info":{"title":"Child API","version":"1.0.0"},"paths":{}}"#; + fs::write(target_dir.join("ChildApp.openapi.json"), spec_content) + .expect("Failed to write spec file"); + + // Save and set CARGO_MANIFEST_DIR + let old_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); + // SAFETY: We're in a single-threaded test context + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + + let processed = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![], + title: Some("Parent API".to_string()), + version: Some("2.0.0".to_string()), + docs_url: Some("/docs".to_string()), + redoc_url: None, + servers: None, + merge: vec![syn::parse_quote!(child::ChildApp)], + }; + let metadata = CollectedMetadata::new(); + + let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); + + // Restore CARGO_MANIFEST_DIR + if let Some(old_value) = old_manifest_dir { + // SAFETY: We're in a single-threaded test context + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", old_value) }; + } + + assert!(result.is_ok()); + } + + #[test] + fn test_generate_and_write_openapi_file_write_error() { + // Line 95: fs::write failure when output path is a directory + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create a directory where the output file should be + let output_path = temp_dir.path().join("openapi.json"); + fs::create_dir(&output_path).expect("Failed to create directory"); + + let processed = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![output_path.to_string_lossy().to_string()], + title: Some("Test API".to_string()), + version: Some("1.0.0".to_string()), + docs_url: None, + redoc_url: None, + servers: None, + merge: vec![], + }; + let metadata = CollectedMetadata::new(); + + let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("failed to write file")); + } + + #[test] + fn test_ensure_openapi_files_from_cache_none_spec() { + // Exercises lines 266-267: early return when spec_pretty is None + let result = ensure_openapi_files_from_cache(&["dummy.json".to_string()], None); + assert!(result.is_ok()); + } + + #[test] + fn test_ensure_openapi_files_from_cache_writes_file() { + // Exercises lines 269-276: write new file + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let output_path = temp_dir.path().join("api.json"); + let spec = r#"{"openapi":"3.1.0"}"#; + + let result = ensure_openapi_files_from_cache( + &[output_path.to_string_lossy().to_string()], + Some(spec), + ); + assert!(result.is_ok()); + assert_eq!(fs::read_to_string(&output_path).unwrap(), spec); + } + + #[test] + fn test_ensure_openapi_files_from_cache_skip_unchanged() { + // Exercises line 271-272: should_write is false when content matches + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let output_path = temp_dir.path().join("api.json"); + let spec = r#"{"openapi":"3.1.0"}"#; + + // Write file first with same content + fs::write(&output_path, spec).unwrap(); + + let result = ensure_openapi_files_from_cache( + &[output_path.to_string_lossy().to_string()], + Some(spec), + ); + assert!(result.is_ok()); + // File should still contain same content (no unnecessary write) + assert_eq!(fs::read_to_string(&output_path).unwrap(), spec); + } + + #[test] + fn test_ensure_openapi_files_from_cache_creates_parent_dirs() { + // Exercises lines 273-274: create parent directories + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let output_path = temp_dir.path().join("nested").join("dir").join("api.json"); + let spec = r#"{"openapi":"3.1.0"}"#; + + let result = ensure_openapi_files_from_cache( + &[output_path.to_string_lossy().to_string()], + Some(spec), + ); + assert!(result.is_ok()); + assert!(output_path.exists()); + assert_eq!(fs::read_to_string(&output_path).unwrap(), spec); + } + + #[test] + fn test_ensure_openapi_files_from_cache_write_error() { + // Exercises line 276: write failure + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let output_path = temp_dir.path().join("api.json"); + + // Create a directory where the file should be -> write will fail + fs::create_dir(&output_path).unwrap(); + + let result = ensure_openapi_files_from_cache( + &[output_path.to_string_lossy().to_string()], + Some("spec"), + ); + assert!(result.is_err()); + } + + #[test] + fn test_ensure_openapi_files_from_cache_multiple_files() { + // Exercises the loop with multiple file names (line 269) + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let path1 = temp_dir.path().join("api1.json"); + let path2 = temp_dir.path().join("api2.json"); + let spec = r#"{"openapi":"3.1.0"}"#; + + let result = ensure_openapi_files_from_cache( + &[ + path1.to_string_lossy().to_string(), + path2.to_string_lossy().to_string(), + ], + Some(spec), + ); + assert!(result.is_ok()); + assert_eq!(fs::read_to_string(&path1).unwrap(), spec); + assert_eq!(fs::read_to_string(&path2).unwrap(), spec); + } +} diff --git a/crates/vespera_macro/src/vespera_impl/orchestrator.rs b/crates/vespera_macro/src/vespera_impl/orchestrator.rs new file mode 100644 index 00000000..308bbca4 --- /dev/null +++ b/crates/vespera_macro/src/vespera_impl/orchestrator.rs @@ -0,0 +1,791 @@ +use std::{collections::HashMap, path::Path}; + +use proc_macro2::Span; +use quote::quote; + +use crate::{ + collector::collect_metadata, + metadata::StructMetadata, + openapi_generator::generate_openapi_doc_with_metadata, + route_impl::StoredRouteInfo, + router_codegen::{ProcessedVesperaInput, generate_router_code}, +}; + +use super::{ + cache::{ + CACHE_FORMAT, VesperaCache, compute_config_hash, compute_macro_dev_fingerprint, + compute_schema_hash, get_cache_path, hash_str, read_cache, write_cache, + }, + openapi_io::{ + ensure_openapi_files_from_cache, generate_and_write_openapi, load_validated_sidecar_specs, + write_pretty_sidecar, write_spec_for_embedding, + }, + path_utils::{find_folder_path, find_target_dir}, + route_merge::merge_route_storage_data, +}; + +/// Process vespera macro - extracted for testability +#[allow(clippy::too_many_lines)] +pub fn process_vespera_macro( + processed: &ProcessedVesperaInput, + schema_storage: &HashMap, + route_storage: &[StoredRouteInfo], +) -> syn::Result { + let profile_start = if std::env::var("VESPERA_PROFILE").is_ok() { + eprintln!( + "[vespera-profile] storage at expansion: {} routes, {} schemas", + route_storage.len(), + schema_storage.len() + ); + Some(std::time::Instant::now()) + } else { + None + }; + + // Stage timer for `VESPERA_PROFILE=1` — prints per-stage elapsed + // times so regressions can be attributed (scan vs openapi vs + // serialization vs codegen). + let mut stage_start = std::time::Instant::now(); + let mut stage = |name: &str| { + if profile_start.is_some() { + eprintln!("[vespera-profile] {name}: {:?}", stage_start.elapsed()); + stage_start = std::time::Instant::now(); + } + }; + + let folder_path = find_folder_path(&processed.folder_name)?; + if !folder_path.exists() { + return Err(syn::Error::new( + Span::call_site(), + format!( + "vespera! macro: route folder '{}' not found. Create src/{} or specify a different folder with `dir = \"your_folder\"`.", + processed.folder_name, processed.folder_name + ), + )); + } + + // --- Incremental cache check --- + // One directory walk serves both the fingerprint map and (on a + // cache miss) route collection below. + let cache_path = get_cache_path(); + let scanned = crate::collector::scan_route_folder(&folder_path) + .map_err(|e| syn::Error::new(Span::call_site(), format!("vespera! macro: {e}")))?; + let fingerprints = crate::collector::fingerprints_from_scan(&scanned); + let schema_hash = compute_schema_hash(schema_storage); + let config_hash = compute_config_hash(processed); + stage("fingerprints + hashes"); + + let macro_version = env!("CARGO_PKG_VERSION").to_string(); + let macro_dev_fingerprint = compute_macro_dev_fingerprint(); + stage("macro_dev_fingerprint"); + let cached = read_cache(&cache_path); + stage("read_cache"); + let cache_hit = cached.as_ref().is_some_and(|c| { + c.cache_format == CACHE_FORMAT + && c.macro_version == macro_version + && c.macro_dev_fingerprint == macro_dev_fingerprint + && c.file_fingerprints == fingerprints + && c.schema_hash == schema_hash + && c.config_hash == config_hash + }); + // Hash-validate the sidecar spec files (the cache only stores + // hashes — content lives in `target/vespera/`). Validation + // failure downgrades to a full regeneration, which rewrites the + // sidecars: corruption self-heals on the next build. + let sidecars = if cache_hit { + let c = cached.as_ref().unwrap(); + load_validated_sidecar_specs(c.spec_json_hash, c.spec_pretty_hash) + } else { + None + }; + stage("validate_sidecar_specs"); + + let (metadata, spec_tokens) = if let Some(sidecars) = sidecars { + let cache = cached.unwrap(); + let mut metadata = cache.metadata; + metadata.structs.extend(schema_storage.values().cloned()); + merge_route_storage_data(&mut metadata, route_storage); + metadata + .check_duplicate_schema_names() + .map_err(|msg| syn::Error::new(Span::call_site(), format!("vespera! macro: {msg}")))?; + stage("cache_branch_metadata_merge"); + + // Ensure openapi.json files exist and are up-to-date from cache + ensure_openapi_files_from_cache(&processed.openapi_file_names, sidecars.pretty.as_deref())?; + stage("ensure_openapi_files_from_cache"); + + (metadata, sidecars.spec_tokens) + } else { + let scanned_files: Vec = + scanned.iter().map(|(path, _)| path.clone()).collect(); + let (mut metadata, file_asts) = crate::collector::collect_metadata_from_files(&scanned_files, &folder_path, &processed.folder_name, route_storage).map_err(|e| syn::Error::new(Span::call_site(), format!("vespera! macro: failed to scan route folder '{}'. Error: {}. Check that all .rs files have valid Rust syntax.", processed.folder_name, e)))?; + stage("collect_metadata"); + + // Clone metadata before extending (cache stores file-only structs) + let cache_metadata = metadata.clone(); + metadata.structs.extend(schema_storage.values().cloned()); + merge_route_storage_data(&mut metadata, route_storage); + metadata + .check_duplicate_schema_names() + .map_err(|msg| syn::Error::new(Span::call_site(), format!("vespera! macro: {msg}")))?; + stage("metadata merge"); + + let (_, _, spec_json) = + generate_and_write_openapi(processed, &metadata, file_asts, route_storage)?; + stage("generate_and_write_openapi"); + + // Read back spec_pretty from first openapi file for the pretty + // sidecar (warm-rebuild recovery source for openapi.json) + let spec_pretty = processed + .openapi_file_names + .first() + .and_then(|f| std::fs::read_to_string(f).ok()); + write_pretty_sidecar(spec_pretty.as_deref()); + + // Persist cache (best-effort, failures are silent) — spec + // contents live in the sidecar files; only hashes are cached. + write_cache( + &cache_path, + &VesperaCache { + cache_format: CACHE_FORMAT, + macro_version: macro_version.clone(), + macro_dev_fingerprint, + file_fingerprints: fingerprints, + schema_hash, + config_hash, + metadata: cache_metadata, + spec_json_hash: spec_json.as_deref().map(hash_str), + spec_pretty_hash: spec_pretty.as_deref().map(hash_str), + }, + ); + stage("write_cache"); + + // Write compact spec for include_str! embedding + let spec_tokens = write_spec_for_embedding(spec_json)?; + stage("write_spec_for_embedding"); + + (metadata, spec_tokens) + }; + + // --- Cron job discovery from CRON_STORAGE --- + // #[cron("...")] attribute already registers metadata at expansion time. + // No folder scanning needed — just read the storage. + let cron_jobs: Vec = { + let storage = crate::CRON_STORAGE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let src_dir = std::env::var("CARGO_MANIFEST_DIR") + .map(|d| { + let p = std::path::PathBuf::from(d).join("src"); + // Canonicalize for reliable prefix stripping + let canonical = p.canonicalize().unwrap_or(p); + canonical.display().to_string().replace('\\', "/") + }) + .unwrap_or_default(); + storage + .iter() + .map(|s| { + // Derive module path from file_path relative to src/ + let module_path = s + .file_path + .as_ref() + .map(|fp| { + let canonical = std::path::Path::new(fp) + .canonicalize() + .map_or_else(|_| fp.clone(), |p| p.display().to_string()); + let normalized = canonical.replace('\\', "/"); + let relative = normalized + .strip_prefix(&src_dir) + .map_or(&*normalized, |rest| rest.trim_start_matches('/')); + // Convert path to module path: strip .rs, replace / with ::, strip mod + // Replace hyphens with underscores (Rust module convention) + relative + .trim_end_matches(".rs") + .replace('/', "::") + .replace('-', "_") + .trim_end_matches("::mod") + .to_string() + }) + .unwrap_or_default(); + crate::metadata::CronMetadata { + expression: s.expression.clone(), + function_name: s.fn_name.clone(), + module_path, + file_path: s.file_path.clone().unwrap_or_default(), + } + }) + .collect() + }; + + let result = Ok(generate_router_code( + &metadata, + processed.docs_url.as_deref(), + processed.redoc_url.as_deref(), + spec_tokens, + &processed.merge, + &cron_jobs, + )); + stage("generate_router_code"); + + if let Some(start) = profile_start { + eprintln!( + "[vespera-profile] vespera! macro total: {:?}", + start.elapsed() + ); + crate::schema_macro::print_profile_summary(); + } + + result +} + +/// Process `export_app` macro - extracted for testability +pub fn process_export_app( + name: &syn::Ident, + folder_name: &str, + schema_storage: &HashMap, + manifest_dir: &str, + route_storage: &[StoredRouteInfo], +) -> syn::Result { + let profile_start = if std::env::var("VESPERA_PROFILE").is_ok() { + Some(std::time::Instant::now()) + } else { + None + }; + + let folder_path = find_folder_path(folder_name)?; + if !folder_path.exists() { + return Err(syn::Error::new( + Span::call_site(), + format!( + "export_app! macro: route folder '{folder_name}' not found. Create src/{folder_name} or specify a different folder with `dir = \"your_folder\"`.", + ), + )); + } + + let (mut metadata, file_asts) = collect_metadata(&folder_path, folder_name, route_storage).map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to scan route folder '{folder_name}'. Error: {e}. Check that all .rs files have valid Rust syntax.")))?; + metadata.structs.extend(schema_storage.values().cloned()); + merge_route_storage_data(&mut metadata, route_storage); + metadata + .check_duplicate_schema_names() + .map_err(|msg| syn::Error::new(Span::call_site(), format!("export_app! macro: {msg}")))?; + + // Generate OpenAPI spec JSON string + let openapi_doc = generate_openapi_doc_with_metadata( + None, + None, + None, + &metadata, + Some(file_asts), + route_storage, + ); + let spec_json = serde_json::to_string(&openapi_doc).map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to serialize OpenAPI spec to JSON. Error: {e}. Check that all schema types are serializable.")))?; + + // Write spec to temp file for compile-time merging by parent apps + let name_str = name.to_string(); + let manifest_path = Path::new(manifest_dir); + let target_dir = find_target_dir(manifest_path); + let vespera_dir = target_dir.join("vespera"); + std::fs::create_dir_all(&vespera_dir).map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to create build cache directory '{}'. Error: {}. Ensure the target directory is writable.", vespera_dir.display(), e)))?; + let spec_file = vespera_dir.join(format!("{name_str}.openapi.json")); + std::fs::write(&spec_file, &spec_json).map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to write OpenAPI spec file '{}'. Error: {}. Ensure the file path is writable.", spec_file.display(), e)))?; + let spec_path_str = spec_file.display().to_string().replace('\\', "/"); + + // Generate router code (without docs routes, no merge) + let router_code = generate_router_code(&metadata, None, None, None, &[], &[]); + + let result = Ok(quote! { + /// Auto-generated vespera app struct + pub struct #name; + + impl #name { + /// OpenAPI specification as JSON string + pub const OPENAPI_SPEC: &'static str = include_str!(#spec_path_str); + + /// Create the router for this app. + /// Returns `Router<()>` which can be merged into any other router. + pub fn router() -> vespera::axum::Router<()> { + #router_code + } + } + }); + + if let Some(start) = profile_start { + eprintln!( + "[vespera-profile] export_app! macro total: {:?}", + start.elapsed() + ); + crate::schema_macro::print_profile_summary(); + } + + result +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::*; + + fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> std::path::PathBuf { + let file_path = dir.path().join(filename); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).expect("Failed to create parent directory"); + } + fs::write(&file_path, content).expect("Failed to write temp file"); + file_path + } + + // ========== Tests for process_vespera_macro ========== + + #[test] + fn test_process_vespera_macro_folder_not_found() { + let processed = ProcessedVesperaInput { + folder_name: "nonexistent_folder_xyz_123".to_string(), + openapi_file_names: vec![], + title: None, + version: None, + docs_url: None, + redoc_url: None, + servers: None, + merge: vec![], + }; + let result = process_vespera_macro(&processed, &HashMap::new(), &[]); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("route folder") && err.contains("not found")); + } + + #[test] + fn test_process_vespera_macro_collect_metadata_error() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create an invalid route file (will cause parse error but collect_metadata handles it) + create_temp_file(&temp_dir, "invalid.rs", "not valid rust code {{{"); + + let processed = ProcessedVesperaInput { + folder_name: temp_dir.path().to_string_lossy().to_string(), + openapi_file_names: vec![], + title: Some("Test API".to_string()), + version: Some("1.0.0".to_string()), + docs_url: None, + redoc_url: None, + servers: None, + merge: vec![], + }; + + // This exercises the collect_metadata path (which handles parse errors gracefully) + let result = process_vespera_macro(&processed, &HashMap::new(), &[]); + // Result may succeed or fail depending on how collect_metadata handles invalid files + let _ = result; + } + + #[test] + fn test_process_vespera_macro_with_schema_storage() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create an empty file (valid but no routes) + create_temp_file(&temp_dir, "empty.rs", "// empty file\n"); + + let schema_storage = HashMap::from([( + "TestSchema".to_string(), + StructMetadata::new( + "TestSchema".to_string(), + "struct TestSchema { id: i32 }".to_string(), + ), + )]); + + let processed = ProcessedVesperaInput { + folder_name: temp_dir.path().to_string_lossy().to_string(), + openapi_file_names: vec![], + title: None, + version: None, + docs_url: Some("/docs".to_string()), + redoc_url: Some("/redoc".to_string()), + servers: None, + merge: vec![], + }; + + // This exercises the schema_storage extend path + let result = process_vespera_macro(&processed, &schema_storage, &[]); + // We only care about exercising the code path + let _ = result; + } + + #[test] + #[serial_test::serial] + fn test_process_vespera_macro_with_cron_storage() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create src/ subfolder structure to simulate a real project + let src_dir = temp_dir.path().join("src"); + std::fs::create_dir_all(src_dir.join("routes")).expect("create routes dir"); + std::fs::write(src_dir.join("routes").join("health.rs"), "// empty\n") + .expect("write health.rs"); + + // Set CARGO_MANIFEST_DIR so module path derivation works + let old_manifest = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { + std::env::set_var( + "CARGO_MANIFEST_DIR", + temp_dir.path().to_string_lossy().as_ref(), + ); + } + + // Populate CRON_STORAGE with a fake cron entry + { + let mut storage = crate::CRON_STORAGE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + storage.push(crate::cron_impl::StoredCronInfo { + fn_name: "test_cron_job".to_string(), + expression: "0 */5 * * * *".to_string(), + file_path: Some( + src_dir + .join("routes") + .join("health.rs") + .display() + .to_string(), + ), + }); + } + + let processed = ProcessedVesperaInput { + folder_name: src_dir.join("routes").to_string_lossy().to_string(), + openapi_file_names: vec![], + title: None, + version: None, + docs_url: None, + redoc_url: None, + servers: None, + merge: vec![], + }; + + // This exercises the CRON_STORAGE → CronMetadata derivation path + let result = process_vespera_macro(&processed, &HashMap::new(), &[]); + assert!( + result.is_ok(), + "Should succeed with cron storage: {result:?}" + ); + + // Clean up CRON_STORAGE + { + let mut storage = crate::CRON_STORAGE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + storage.retain(|s| s.fn_name != "test_cron_job"); + } + + // Restore CARGO_MANIFEST_DIR + unsafe { + if let Some(val) = old_manifest { + std::env::set_var("CARGO_MANIFEST_DIR", val); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + } + + // ========== Tests for process_export_app ========== + + #[test] + fn test_process_export_app_folder_not_found() { + let name: syn::Ident = syn::parse_quote!(TestApp); + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let result = process_export_app( + &name, + "nonexistent_folder_xyz", + &HashMap::new(), + &temp_dir.path().to_string_lossy(), + &[], + ); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("route folder") && err.contains("not found")); + } + + #[test] + fn test_process_export_app_with_empty_folder() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create an empty file + create_temp_file(&temp_dir, "empty.rs", "// empty\n"); + + let name: syn::Ident = syn::parse_quote!(TestApp); + let folder_path = temp_dir.path().to_string_lossy().to_string(); + + // This exercises collect_metadata and other paths + let result = process_export_app( + &name, + &folder_path, + &HashMap::new(), + &temp_dir.path().to_string_lossy(), + &[], + ); + // We only care about exercising the code path + let _ = result; + } + + #[test] + fn test_process_export_app_with_schema_storage() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create an empty but valid Rust file + create_temp_file(&temp_dir, "mod.rs", "// module file\n"); + + let schema_storage = HashMap::from([( + "AppSchema".to_string(), + StructMetadata::new( + "AppSchema".to_string(), + "struct AppSchema { name: String }".to_string(), + ), + )]); + + let name: syn::Ident = syn::parse_quote!(MyExportedApp); + let folder_path = temp_dir.path().to_string_lossy().to_string(); + + let result = process_export_app( + &name, + &folder_path, + &schema_storage, + &temp_dir.path().to_string_lossy(), + &[], + ); + // Exercises the schema_storage.extend path + let _ = result; + } + + #[test] + fn test_process_export_app_collect_metadata_error() { + // Lines 210-212: collect_metadata returns error for invalid Rust syntax + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create a file with invalid Rust syntax that will cause parse error + create_temp_file(&temp_dir, "invalid.rs", "fn broken( { syntax error"); + + let name: syn::Ident = syn::parse_quote!(TestApp); + let folder_path = temp_dir.path().to_string_lossy().to_string(); + + let result = process_export_app( + &name, + &folder_path, + &HashMap::new(), + &temp_dir.path().to_string_lossy(), + &[], + ); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("failed to scan route folder")); + } + + #[test] + fn test_process_export_app_create_dir_error() { + // Lines 232-234: create_dir_all failure when path contains a file + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create an empty valid Rust file + create_temp_file(&temp_dir, "empty.rs", "// empty file\n"); + + // Create target directory but make 'vespera' a file instead of directory + let target_dir = temp_dir.path().join("target"); + fs::create_dir(&target_dir).expect("Failed to create target dir"); + fs::write(target_dir.join("vespera"), "blocking file").expect("Failed to write file"); + + let name: syn::Ident = syn::parse_quote!(TestApp); + let folder_path = temp_dir.path().to_string_lossy().to_string(); + + let result = process_export_app( + &name, + &folder_path, + &HashMap::new(), + &temp_dir.path().to_string_lossy(), + &[], + ); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("failed to create build cache directory")); + } + + #[test] + fn test_process_export_app_write_spec_error() { + // Lines 239-241: fs::write failure when spec file path is a directory + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create an empty valid Rust file + create_temp_file(&temp_dir, "empty.rs", "// empty file\n"); + + // Create target/vespera directory and make spec file name a directory + let vespera_dir = temp_dir.path().join("target").join("vespera"); + fs::create_dir_all(&vespera_dir).expect("Failed to create vespera dir"); + // Create a directory where the spec file should be written + fs::create_dir(vespera_dir.join("TestApp.openapi.json")) + .expect("Failed to create blocking dir"); + + let name: syn::Ident = syn::parse_quote!(TestApp); + let folder_path = temp_dir.path().to_string_lossy().to_string(); + + let result = process_export_app( + &name, + &folder_path, + &HashMap::new(), + &temp_dir.path().to_string_lossy(), + &[], + ); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("failed to write OpenAPI spec file")); + } + #[test] + fn test_process_vespera_macro_no_openapi_output() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + create_temp_file(&temp_dir, "empty.rs", "// empty route file\n"); + + let processed = ProcessedVesperaInput { + folder_name: temp_dir.path().to_string_lossy().to_string(), + openapi_file_names: vec![], + title: None, + version: None, + docs_url: None, + redoc_url: None, + servers: None, + merge: vec![], + }; + + let result = process_vespera_macro(&processed, &HashMap::new(), &[]); + assert!( + result.is_ok(), + "Should succeed with no openapi output configured" + ); + } + + #[test] + #[serial_test::serial] + fn test_process_vespera_macro_with_profiling() { + let old_profile = std::env::var("VESPERA_PROFILE").ok(); + unsafe { std::env::set_var("VESPERA_PROFILE", "1") }; + + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + create_temp_file(&temp_dir, "empty.rs", "// empty\n"); + + let processed = ProcessedVesperaInput { + folder_name: temp_dir.path().to_string_lossy().to_string(), + openapi_file_names: vec![], + title: None, + version: None, + docs_url: None, + redoc_url: None, + servers: None, + merge: vec![], + }; + + let result = process_vespera_macro(&processed, &HashMap::new(), &[]); + + // Restore + unsafe { + if let Some(val) = old_profile { + std::env::set_var("VESPERA_PROFILE", val); + } else { + std::env::remove_var("VESPERA_PROFILE"); + } + }; + + assert!(result.is_ok()); + } + + #[test] + #[serial_test::serial] + fn test_process_export_app_with_profiling() { + let old_profile = std::env::var("VESPERA_PROFILE").ok(); + unsafe { std::env::set_var("VESPERA_PROFILE", "1") }; + + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + create_temp_file(&temp_dir, "empty.rs", "// empty\n"); + + let name: syn::Ident = syn::parse_quote!(TestProfileApp); + let folder_path = temp_dir.path().to_string_lossy().to_string(); + + let result = process_export_app( + &name, + &folder_path, + &HashMap::new(), + &temp_dir.path().to_string_lossy(), + &[], + ); + + // Restore + unsafe { + if let Some(val) = old_profile { + std::env::set_var("VESPERA_PROFILE", val); + } else { + std::env::remove_var("VESPERA_PROFILE"); + } + }; + + // Exercise the code path + let _ = result; + } + + #[test] + #[serial_test::serial] + fn test_process_vespera_macro_cache_hit() { + // Exercises lines 320-324, 327, 329: the cache_hit branch in process_vespera_macro. + // First call populates the cache, second call hits it. + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + create_temp_file( + &temp_dir, + "users.rs", + "pub async fn list_users() -> String { \"users\".to_string() }\n", + ); + + let folder_path = temp_dir.path().to_string_lossy().to_string(); + let openapi_path = temp_dir.path().join("openapi.json"); + + // Set CARGO_MANIFEST_DIR so cache path resolves to temp_dir/target/vespera/ + let old_manifest = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + + let processed = ProcessedVesperaInput { + folder_name: folder_path.clone(), + openapi_file_names: vec![openapi_path.to_string_lossy().to_string()], + title: Some("Test API".to_string()), + version: Some("1.0.0".to_string()), + docs_url: Some("/docs".to_string()), + redoc_url: None, + servers: None, + merge: vec![], + }; + + // First call: cache MISS — scans files, generates spec, writes cache + let result1 = process_vespera_macro(&processed, &HashMap::new(), &[]); + assert!( + result1.is_ok(), + "First call (cache miss) should succeed: {:?}", + result1.err() + ); + assert!( + openapi_path.exists(), + "openapi.json should be written on first call" + ); + + // Second call: cache HIT — exercises lines 320-324, 327, 329 + let result2 = process_vespera_macro(&processed, &HashMap::new(), &[]); + assert!( + result2.is_ok(), + "Second call (cache hit) should succeed: {:?}", + result2.err() + ); + + // Restore CARGO_MANIFEST_DIR + unsafe { + if let Some(val) = old_manifest { + std::env::set_var("CARGO_MANIFEST_DIR", val); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + }; + } +} diff --git a/crates/vespera_macro/src/vespera_impl/path_utils.rs b/crates/vespera_macro/src/vespera_impl/path_utils.rs new file mode 100644 index 00000000..7dd9809b --- /dev/null +++ b/crates/vespera_macro/src/vespera_impl/path_utils.rs @@ -0,0 +1,216 @@ +use std::path::Path; + +use crate::error::{MacroResult, err_call_site}; + +/// Name of the crate currently being expanded, for namespacing files +/// under the (workspace-shared) `target/vespera/` directory. Two +/// workspace members both using `vespera!` would otherwise overwrite +/// each other's cache (permanent miss ping-pong) and — worse — race on +/// the shared spec file that the generated code `include_str!`s. +pub(super) fn current_crate_tag() -> String { + std::env::var("CARGO_PKG_NAME").unwrap_or_else(|_| "default".to_string()) +} + +/// Find the folder path for route scanning +pub fn find_folder_path(folder_name: &str) -> MacroResult { + let root = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| { + err_call_site( + "CARGO_MANIFEST_DIR is not set. vespera macros must be used within a cargo build.", + ) + })?; + let path = format!("{root}/src/{folder_name}"); + let path = Path::new(&path); + if path.exists() && path.is_dir() { + return Ok(path.to_path_buf()); + } + + Ok(Path::new(folder_name).to_path_buf()) +} + +/// Find the workspace root's target directory +pub fn find_target_dir(manifest_path: &Path) -> std::path::PathBuf { + // Look for workspace root by finding a Cargo.toml with [workspace] section + let mut current = Some(manifest_path); + let mut last_with_lock = None; + + while let Some(dir) = current { + // Check if this directory has Cargo.lock + if dir.join("Cargo.lock").exists() { + last_with_lock = Some(dir.to_path_buf()); + } + + // Check if this is a workspace root (has Cargo.toml with [workspace]). + // `read_to_string` already fails when the file does not exist, so the + // previous `.exists()` pre-flight is redundant — drop it to save one + // stat per iteration of the walk. + if let Ok(contents) = std::fs::read_to_string(dir.join("Cargo.toml")) + && contents.contains("[workspace]") + { + return dir.join("target"); + } + + current = dir.parent(); + } + + // If we found a Cargo.lock but no [workspace], use the topmost one + if let Some(lock_dir) = last_with_lock { + return lock_dir.join("target"); + } + + // Fallback: use manifest dir's target + manifest_path.join("target") +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::*; + + #[test] + fn test_find_folder_path_nonexistent_returns_path() { + // When the constructed path doesn't exist, it falls back to using folder_name directly + let result = find_folder_path("nonexistent_folder_xyz").unwrap(); + // It should return a PathBuf (either from src/nonexistent... or just the folder name) + assert!(result.to_string_lossy().contains("nonexistent_folder_xyz")); + } + + // ========== Tests for find_target_dir ========== + + #[test] + fn test_find_target_dir_no_workspace() { + // Test fallback to manifest dir's target + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let manifest_path = temp_dir.path(); + let result = find_target_dir(manifest_path); + assert_eq!(result, manifest_path.join("target")); + } + + #[test] + fn test_find_target_dir_with_cargo_lock() { + // Test finding target dir with Cargo.lock present + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let manifest_path = temp_dir.path(); + + // Create Cargo.lock (but no [workspace] in Cargo.toml) + fs::write(manifest_path.join("Cargo.lock"), "").expect("Failed to write Cargo.lock"); + + let result = find_target_dir(manifest_path); + // Should use the directory with Cargo.lock + assert_eq!(result, manifest_path.join("target")); + } + + #[test] + fn test_find_target_dir_with_workspace() { + // Test finding workspace root + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let workspace_root = temp_dir.path(); + + // Create a workspace Cargo.toml + fs::write( + workspace_root.join("Cargo.toml"), + "[workspace]\nmembers = [\"crate1\"]", + ) + .expect("Failed to write Cargo.toml"); + + // Create nested crate directory + let crate_dir = workspace_root.join("crate1"); + fs::create_dir(&crate_dir).expect("Failed to create crate dir"); + fs::write(crate_dir.join("Cargo.toml"), "[package]\nname = \"crate1\"") + .expect("Failed to write Cargo.toml"); + + let result = find_target_dir(&crate_dir); + // Should return workspace root's target + assert_eq!(result, workspace_root.join("target")); + } + + #[test] + fn test_find_target_dir_workspace_with_cargo_lock() { + // Test that [workspace] takes priority over Cargo.lock + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let workspace_root = temp_dir.path(); + + // Create workspace Cargo.toml and Cargo.lock + fs::write( + workspace_root.join("Cargo.toml"), + "[workspace]\nmembers = [\"crate1\"]", + ) + .expect("Failed to write Cargo.toml"); + fs::write(workspace_root.join("Cargo.lock"), "").expect("Failed to write Cargo.lock"); + + // Create nested crate + let crate_dir = workspace_root.join("crate1"); + fs::create_dir(&crate_dir).expect("Failed to create crate dir"); + fs::write(crate_dir.join("Cargo.toml"), "[package]\nname = \"crate1\"") + .expect("Failed to write Cargo.toml"); + + let result = find_target_dir(&crate_dir); + assert_eq!(result, workspace_root.join("target")); + } + + #[test] + fn test_find_target_dir_deeply_nested() { + // Test deeply nested crate structure + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let workspace_root = temp_dir.path(); + + // Create workspace + fs::write( + workspace_root.join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/*\"]", + ) + .expect("Failed to write Cargo.toml"); + + // Create deeply nested crate + let deep_crate = workspace_root.join("crates/group/my-crate"); + fs::create_dir_all(&deep_crate).expect("Failed to create nested dirs"); + fs::write(deep_crate.join("Cargo.toml"), "[package]").expect("Failed to write Cargo.toml"); + + let result = find_target_dir(&deep_crate); + assert_eq!(result, workspace_root.join("target")); + } + + #[test] + fn test_find_folder_path_absolute_path() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let absolute_path = temp_dir.path().to_string_lossy().to_string(); + + // When given an absolute path that exists, it should return it + let result = find_folder_path(&absolute_path).unwrap(); + // The function tries src/{folder_name} first, then falls back to the folder_name directly + assert!( + result.to_string_lossy().contains(&absolute_path) + || result == Path::new(&absolute_path) + ); + } + + #[serial_test::serial] + #[test] + fn test_find_folder_path_with_src_folder() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create src/routes directory + let src_routes = temp_dir.path().join("src").join("routes"); + fs::create_dir_all(&src_routes).expect("Failed to create src/routes dir"); + + // Save and set CARGO_MANIFEST_DIR + let old_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); + // SAFETY: We're in a single-threaded test context + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + + let result = find_folder_path("routes").unwrap(); + + // Restore CARGO_MANIFEST_DIR + if let Some(old_value) = old_manifest_dir { + // SAFETY: We're in a single-threaded test context + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", old_value) }; + } + + // Should return the src/routes path since it exists + assert!( + result.to_string_lossy().contains("src") && result.to_string_lossy().contains("routes") + ); + } +} diff --git a/crates/vespera_macro/src/vespera_impl/route_merge.rs b/crates/vespera_macro/src/vespera_impl/route_merge.rs new file mode 100644 index 00000000..5bc1474f --- /dev/null +++ b/crates/vespera_macro/src/vespera_impl/route_merge.rs @@ -0,0 +1,264 @@ +use std::collections::HashMap; + +use crate::{metadata::CollectedMetadata, route_impl::StoredRouteInfo}; + +/// Supplement collector's `RouteMetadata` with data from `ROUTE_STORAGE`. +/// +/// `#[route]` stores metadata at attribute expansion time. +/// `collector.rs` re-parses the same data from file ASTs. +/// This function merges ROUTE_STORAGE data into collector's output, +/// preferring ROUTE_STORAGE values when they provide richer info. +/// +/// Matching is by function name. If multiple routes share a function name, +/// the match is ambiguous and ROUTE_STORAGE data is skipped for safety. +pub(super) fn merge_route_storage_data( + metadata: &mut CollectedMetadata, + route_storage: &[StoredRouteInfo], +) { + if route_storage.is_empty() { + return; + } + + // Build `fn_name -> Option<&StoredRouteInfo>` index in a single pass: + // `Some(_)` when the name is unique, `None` when it is ambiguous + // (appears more than once). This turns the previous O(N*M) nested + // scan into O(N + M). + let mut stored_index: HashMap<&str, Option<&StoredRouteInfo>> = + HashMap::with_capacity(route_storage.len()); + for stored in route_storage { + stored_index + .entry(stored.fn_name.as_str()) + .and_modify(|slot| *slot = None) + .or_insert(Some(stored)); + } + + for route in &mut metadata.routes { + // Skip if no match or ambiguous (multiple routes share fn_name). + let Some(Some(stored)) = stored_index.get(route.function_name.as_str()) else { + continue; + }; + + // Supplement with ROUTE_STORAGE data — only override when an + // explicit value is present. + if let Some(ref tags) = stored.tags { + route.tags = Some(tags.clone()); + } + if let Some(ref desc) = stored.description { + route.description = Some(desc.clone()); + } + if let Some(ref status) = stored.error_status { + route.error_status = Some(status.clone()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::metadata::RouteMetadata; + + // ========== Tests for merge_route_storage_data ========== + + #[test] + fn test_merge_route_storage_empty_storage() { + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(RouteMetadata { + method: "get".to_string(), + path: "/users".to_string(), + function_name: "get_users".to_string(), + module_path: "routes".to_string(), + file_path: "routes/users.rs".to_string(), + error_status: None, + tags: None, + description: None, + }); + + merge_route_storage_data(&mut metadata, &[]); + // No changes when storage is empty + assert!(metadata.routes[0].tags.is_none()); + assert!(metadata.routes[0].description.is_none()); + assert!(metadata.routes[0].error_status.is_none()); + } + + #[test] + fn test_merge_route_storage_matching_route() { + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(RouteMetadata { + method: "get".to_string(), + path: "/users".to_string(), + function_name: "get_users".to_string(), + module_path: "routes".to_string(), + file_path: "routes/users.rs".to_string(), + error_status: None, + tags: None, + description: None, + }); + + let storage = vec![StoredRouteInfo { + fn_name: "get_users".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: Some(vec![400, 404]), + tags: Some(vec!["users".to_string()]), + description: Some("List all users".to_string()), + fn_item_str: String::new(), + file_path: None, + }]; + + merge_route_storage_data(&mut metadata, &storage); + assert_eq!(metadata.routes[0].tags, Some(vec!["users".to_string()])); + assert_eq!( + metadata.routes[0].description, + Some("List all users".to_string()) + ); + assert_eq!(metadata.routes[0].error_status, Some(vec![400, 404])); + } + + #[test] + fn test_merge_route_storage_no_match() { + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(RouteMetadata { + method: "get".to_string(), + path: "/users".to_string(), + function_name: "get_users".to_string(), + module_path: "routes".to_string(), + file_path: "routes/users.rs".to_string(), + error_status: None, + tags: None, + description: None, + }); + + let storage = vec![StoredRouteInfo { + fn_name: "create_user".to_string(), + method: Some("post".to_string()), + custom_path: None, + error_status: Some(vec![400]), + tags: Some(vec!["users".to_string()]), + description: None, + fn_item_str: String::new(), + file_path: None, + }]; + + merge_route_storage_data(&mut metadata, &storage); + // No match — fields unchanged + assert!(metadata.routes[0].tags.is_none()); + assert!(metadata.routes[0].error_status.is_none()); + } + + #[test] + fn test_merge_route_storage_ambiguous_skipped() { + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(RouteMetadata { + method: "get".to_string(), + path: "/users".to_string(), + function_name: "handler".to_string(), + module_path: "routes".to_string(), + file_path: "routes/users.rs".to_string(), + error_status: None, + tags: None, + description: None, + }); + + // Two StoredRouteInfo with same fn_name — ambiguous + let storage = vec![ + StoredRouteInfo { + fn_name: "handler".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: None, + tags: Some(vec!["file-a".to_string()]), + description: None, + fn_item_str: String::new(), + file_path: None, + }, + StoredRouteInfo { + fn_name: "handler".to_string(), + method: Some("post".to_string()), + custom_path: None, + error_status: None, + tags: Some(vec!["file-b".to_string()]), + description: None, + fn_item_str: String::new(), + file_path: None, + }, + ]; + + merge_route_storage_data(&mut metadata, &storage); + // Ambiguous match — no merge + assert!(metadata.routes[0].tags.is_none()); + } + + #[test] + fn test_merge_route_storage_preserves_existing() { + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(RouteMetadata { + method: "get".to_string(), + path: "/users".to_string(), + function_name: "get_users".to_string(), + module_path: "routes".to_string(), + file_path: "routes/users.rs".to_string(), + error_status: Some(vec![500]), + tags: Some(vec!["existing-tag".to_string()]), + description: Some("Existing description".to_string()), + }); + + let storage = vec![StoredRouteInfo { + fn_name: "get_users".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: Some(vec![400, 404]), + tags: Some(vec!["new-tag".to_string()]), + description: Some("New description".to_string()), + fn_item_str: String::new(), + file_path: None, + }]; + + merge_route_storage_data(&mut metadata, &storage); + // ROUTE_STORAGE values override when they have explicit values + assert_eq!(metadata.routes[0].tags, Some(vec!["new-tag".to_string()])); + assert_eq!( + metadata.routes[0].description, + Some("New description".to_string()) + ); + assert_eq!(metadata.routes[0].error_status, Some(vec![400, 404])); + } + + #[test] + fn test_merge_route_storage_partial_fields() { + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(RouteMetadata { + method: "get".to_string(), + path: "/users".to_string(), + function_name: "get_users".to_string(), + module_path: "routes".to_string(), + file_path: "routes/users.rs".to_string(), + error_status: None, + tags: Some(vec!["from-collector".to_string()]), + description: Some("From doc comment".to_string()), + }); + + // StoredRouteInfo with only error_status (tags/description are None) + let storage = vec![StoredRouteInfo { + fn_name: "get_users".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: Some(vec![400]), + tags: None, + description: None, + fn_item_str: String::new(), + file_path: None, + }]; + + merge_route_storage_data(&mut metadata, &storage); + // Only error_status should be set; tags and description preserved from collector + assert_eq!( + metadata.routes[0].tags, + Some(vec!["from-collector".to_string()]) + ); + assert_eq!( + metadata.routes[0].description, + Some("From doc comment".to_string()) + ); + assert_eq!(metadata.routes[0].error_status, Some(vec![400])); + } +} diff --git a/examples/axum-example/Cargo.lock b/examples/axum-example/Cargo.lock deleted file mode 100644 index eeace9b2..00000000 --- a/examples/axum-example/Cargo.lock +++ /dev/null @@ -1,1591 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "axum" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" -dependencies = [ - "axum-core", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-example" -version = "0.1.0" -dependencies = [ - "axum", - "axum-test", - "serde", - "serde_json", - "tokio", - "vespera", -] - -[[package]] -name = "axum-test" -version = "18.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0388808c0617a886601385c0024b9d0162480a763ba371f803d87b775115400" -dependencies = [ - "anyhow", - "axum", - "bytes", - "bytesize", - "cookie", - "expect-json", - "http", - "http-body-util", - "hyper", - "hyper-util", - "mime", - "pretty_assertions", - "reserve-port", - "rust-multipart-rfc7578_2", - "serde", - "serde_json", - "serde_urlencoded", - "smallvec", - "tokio", - "tower", - "url", -] - -[[package]] -name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" - -[[package]] -name = "bumpalo" -version = "3.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" - -[[package]] -name = "bytes" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" - -[[package]] -name = "bytesize" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00f4369ba008f82b968b1acbe31715ec37bd45236fa0726605a36cc3060ea256" - -[[package]] -name = "cc" -version = "1.2.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "chrono" -version = "0.4.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "time", - "version_check", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "deranged" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "diff" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" - -[[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 = "email_address" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" -dependencies = [ - "serde", -] - -[[package]] -name = "erased-serde" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - -[[package]] -name = "expect-json" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7519e78573c950576b89eb4f4fe82aedf3a80639245afa07e3ee3d199dcdb29e" -dependencies = [ - "chrono", - "email_address", - "expect-json-macros", - "num", - "serde", - "serde_json", - "thiserror", - "typetag", - "uuid", -] - -[[package]] -name = "expect-json-macros" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bf7f5979e98460a0eb412665514594f68f366a32b85fa8d7ffb65bb1edee6a0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" - -[[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-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-core", - "futures-io", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-util" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http", - "http-body", - "hyper", - "libc", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[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.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "inventory" -version = "0.3.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" -dependencies = [ - "rustversion", -] - -[[package]] -name = "itoa" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" - -[[package]] -name = "js-sys" -version = "0.3.82" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "libc" -version = "0.2.177" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" - -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "memchr" -version = "2.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mio" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[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.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "pretty_assertions" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" -dependencies = [ - "diff", - "yansi", -] - -[[package]] -name = "proc-macro2" -version = "1.0.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[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", -] - -[[package]] -name = "rand_core" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" -dependencies = [ - "getrandom", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "reserve-port" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356" -dependencies = [ - "thiserror", -] - -[[package]] -name = "rust-multipart-rfc7578_2" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c839d037155ebc06a571e305af66ff9fd9063a6e662447051737e1ac75beea41" -dependencies = [ - "bytes", - "futures-core", - "futures-util", - "http", - "mime", - "rand", - "thiserror", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[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.145" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" -dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", - "serde_core", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" -dependencies = [ - "libc", -] - -[[package]] -name = "slab" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - -[[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.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" - -[[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 = "thiserror" -version = "2.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "time" -version = "0.3.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" - -[[package]] -name = "time-macros" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tokio" -version = "1.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tower" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" -dependencies = [ - "log", - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - -[[package]] -name = "typetag" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf" -dependencies = [ - "erased-serde", - "inventory", - "once_cell", - "serde", - "typetag-impl", -] - -[[package]] -name = "typetag-impl" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "unicode-ident" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" - -[[package]] -name = "url" -version = "2.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" -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 = "uuid" -version = "1.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vespera" -version = "0.1.0" -dependencies = [ - "anyhow", - "proc-macro2", - "quote", - "serde", - "serde_json", - "syn", - "vespera_core", -] - -[[package]] -name = "vespera_core" -version = "0.1.0" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea879c944afe8a2b25fef16bb4ba234f47c694565e97383b36f3a878219065c" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf955aa904d6040f70dc8e9384444cb1030aed272ba3cb09bbc4ab9e7c1f34f5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] diff --git a/examples/axum-example/Cargo.toml b/examples/axum-example/Cargo.toml index 38c6e44c..e0806345 100644 --- a/examples/axum-example/Cargo.toml +++ b/examples/axum-example/Cargo.toml @@ -10,7 +10,7 @@ tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tower-http = { version = "0.6", features = ["cors"] } -sea-orm = { version = "^2.0.0-rc.38", features = ["sqlx-sqlite", "runtime-tokio-rustls", "macros", "with-uuid"] } +sea-orm = { version = "^2.0.0-rc.40", features = ["sqlx-sqlite", "runtime-tokio-rustls", "macros", "with-uuid"] } uuid = { version = "1", features = ["v4", "serde"] } tempfile = "3" @@ -20,6 +20,6 @@ third = { path = "../third" } vespera = { path = "../../crates/vespera" } [dev-dependencies] -axum-test = "20.0" -insta = "1.47" +axum-test = "20.1" +insta = "1.48" diff --git a/examples/rust-jni-demo/Cargo.lock b/examples/rust-jni-demo/Cargo.lock deleted file mode 100644 index fd84f935..00000000 --- a/examples/rust-jni-demo/Cargo.lock +++ /dev/null @@ -1,1454 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "axum" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" -dependencies = [ - "axum-core", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "multer", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-extra" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76" -dependencies = [ - "axum", - "axum-core", - "bytes", - "cookie", - "fastrand", - "form_urlencoded", - "futures-core", - "futures-util", - "headers", - "http", - "http-body", - "http-body-util", - "mime", - "multer", - "pin-project-lite", - "serde_core", - "serde_html_form", - "serde_path_to_error", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bitflags" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "cc" -version = "1.2.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[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-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", - "wasip3", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] -name = "headers" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" -dependencies = [ - "base64", - "bytes", - "headers-core", - "http", - "httpdate", - "mime", - "sha1", -] - -[[package]] -name = "headers-core" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" -dependencies = [ - "http", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec", - "tokio", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "bytes", - "http", - "http-body", - "hyper", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "indexmap" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", -] - -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys", - "log", - "thiserror", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni-sys" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" - -[[package]] -name = "js-sys" -version = "0.3.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libc" -version = "0.2.183" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mio" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "multer" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" -dependencies = [ - "bytes", - "encoding_rs", - "futures-util", - "http", - "httparse", - "memchr", - "mime", - "spin", - "version_check", -] - -[[package]] -name = "num-conv" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[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 = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rust-jni-demo" -version = "0.1.0" -dependencies = [ - "axum", - "jni", - "serde", - "serde_json", - "tokio", - "vespera", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[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 = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" - -[[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_html_form" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" -dependencies = [ - "form_urlencoded", - "indexmap", - "itoa", - "ryu", - "serde_core", -] - -[[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 = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[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 = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" - -[[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 = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl", -] - -[[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", -] - -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tokio" -version = "1.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" -dependencies = [ - "libc", - "mio", - "pin-project-lite", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[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 = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vespera" -version = "0.1.44" -dependencies = [ - "axum", - "axum-extra", - "chrono", - "http", - "http-body-util", - "jni", - "serde", - "serde_json", - "tempfile", - "tokio", - "tower", - "tower-layer", - "tower-service", - "vespera_core", - "vespera_macro", -] - -[[package]] -name = "vespera_core" -version = "0.1.44" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "vespera_macro" -version = "0.1.44" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "serde_json", - "syn", - "vespera_core", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/examples/rust-jni-demo/Cargo.toml b/examples/rust-jni-demo/Cargo.toml index a4f2c955..b1a7ac9d 100644 --- a/examples/rust-jni-demo/Cargo.toml +++ b/examples/rust-jni-demo/Cargo.toml @@ -12,7 +12,7 @@ name = "rust-jni-demo" path = "src/main.rs" [dependencies] -vespera = { path = "../../crates/vespera", features = ["jni"] } +vespera = { path = "../../crates/vespera", features = ["jni", "mimalloc"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"] } diff --git a/examples/rust-jni-demo/README.md b/examples/rust-jni-demo/README.md index b4709e90..6d77d1fc 100644 --- a/examples/rust-jni-demo/README.md +++ b/examples/rust-jni-demo/README.md @@ -161,7 +161,7 @@ public class DemoApplication { 3. `VesperaProxyController` catches all HTTP requests → encodes them into the **binary wire format** via `VesperaBridge.encodeRequest(...)` → calls `VesperaBridge.dispatchBytes(byte[])` 4. JNI symbol delegates to `vespera::inprocess::dispatch_from_bytes()` 5. `dispatch_from_bytes` parses the wire header, looks up the cached `Router`, and runs `router.oneshot(request)` with the raw body bytes -6. Response wire bytes flow back the same way; `VesperaBridge.decodeResponse(byte[])` produces a `DecodedResponse` and the controller returns either `ResponseEntity` (text-like Content-Type) or `ResponseEntity` (binary) +6. Response wire bytes flow back the same way; the controller parses status + headers straight from the wire via `WireHeaderReader` and returns `ResponseEntity` for every content type (the wire header carries the exact `Content-Type`, written verbatim — no UTF-8 round-trip) 7. No TCP between Java and Rust; **no base64** — multipart uploads, PDFs, images travel as raw bytes #### Wire format @@ -188,9 +188,9 @@ All failure paths (malformed wire, Rust panic, no app registered) return a lengt ```kotlin // build.gradle.kts repositories { - maven { url = uri("https://maven.pkg.github.com/dev-five-git/vespera") } + mavenCentral() } dependencies { - implementation("com.devfive.vespera:vespera-bridge:0.1.0") + implementation("kr.devfive:vespera-bridge:0.2.0") } ``` diff --git a/examples/rust-jni-demo/java/demo-app/build.gradle.kts b/examples/rust-jni-demo/java/demo-app/build.gradle.kts index 12aeda20..88fc63d1 100644 --- a/examples/rust-jni-demo/java/demo-app/build.gradle.kts +++ b/examples/rust-jni-demo/java/demo-app/build.gradle.kts @@ -12,7 +12,7 @@ plugins { // detection helpers, library-name mapping, processResources wiring). // After: the 5-line `vespera { ... }` block below. // ─────────────────────────────────────────────────────────────────── - id("kr.devfive.vespera-bridge") version "0.0.15" + id("kr.devfive.vespera-bridge") version "0.1.1" } group = "kr.go.demo" @@ -21,7 +21,9 @@ version = "0.1.0" vespera { crateName.set("rust_jni_demo") cargoRoot.set(rootProject.layout.projectDirectory.dir("../../..")) - bridgeVersion.set("0.0.15") + // Dogfoods the locally published bridge (./gradlew publishToMavenLocal + // in libs/vespera-bridge) — required for the dispatchDirect E2E tests. + bridgeVersion.set("0.1.1") } dependencies { @@ -32,4 +34,17 @@ dependencies { tasks.test { useJUnitPlatform() + // Propagate streaming bench knobs from the Gradle CLI into the + // forked test JVM (chunk size is process-fixed, so each value + // needs its own `gradlew test -D...` run). + listOf( + "vespera.bench", + "vespera.streaming.chunkBytes", + "vespera.streaming.channelCapacity", + "vespera.runtime.workerThreads", + ).forEach { key -> + System.getProperty(key)?.let { systemProperty(key, it) } + } + // Bench output is read from stdout. + testLogging.showStandardStreams = true } diff --git a/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/AllocationBenchTest.java b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/AllocationBenchTest.java new file mode 100644 index 00000000..3cb24ca4 --- /dev/null +++ b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/AllocationBenchTest.java @@ -0,0 +1,234 @@ +package kr.go.demo; + +import com.devfive.vespera.bridge.VesperaBridge; +import com.sun.management.ThreadMXBean; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.lang.management.ManagementFactory; +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +/** + * E2E JNI allocation benchmark gated behind + * {@code -Dvespera.bench=true} — companion to {@link SmallRequestLatencyBenchTest}. + * + *

Measures JVM bytes allocated per dispatch on the calling + * thread for each of the five dispatch modes, using + * {@link com.sun.management.ThreadMXBean#getThreadAllocatedBytes(long)}. + * Quantifies the memory dimension that the recently-landed streaming + * chunk-buffer TLS pooling targets. + * + *

Why calling-thread measurement captures the pooling win

+ * + *

The streaming JNI entries + * ({@code Java_..._dispatchFullStreamingWithHeader} and friends in + * {@code crates/vespera_jni/src/jni_impl.rs}) allocate the Java byte[] chunk + * buffers via {@code env.new_byte_array(...)} on the JNI entry thread + * — i.e. the calling thread. Same for {@code set_region} / {@code get_region} + * on those arrays. Before TLS pooling those landed as fresh JVM allocations + * per dispatch; after pooling the same {@code GlobalRef} is + * reused across calls, so the calling-thread allocation count drops to + * effectively the request/response wire bytes plus a few small Java objects. + * + *

Async caveat (honest)

+ * + *

For {@code async_completable_future}, the {@code CompletableFuture} + * completion happens on a Rust Tokio worker thread (a daemon-attached + * cached worker), not the calling thread. This measurement therefore + * captures only what the caller pays: encoding the request, + * constructing the future, and {@code future.get()}-side allocations. + * Completion-side allocations on the daemon thread are not visible here + * and would require per-thread {@code getThreadAllocatedBytes} on the + * worker, which we don't observe by design. + * + *

Protocol

+ * + *
    + *
  • {@code WARMUP=5_000} iterations to stabilize JIT / inlining / + * TLS-pool fill. + *
  • {@code MEASURE=20_000} iterations; bytes/op = + * {@code (allocAfter - allocBefore) / MEASURE}. + *
  • Single-threaded loop, pinned to one calling thread. + *
  • Loop body keeps no per-iteration objects in Java besides what the + * dispatch helpers themselves create — the measurement-harness's own + * per-op allocation is intentionally zero (a {@code long} blackhole + * accumulator only). + *
+ * + *

Output

+ * + *

One line per mode (parseable, same style as {@code VESPERA_BENCH}): + *

VESPERA_ALLOC <mode> bytes_per_op=<N>
+ * + *

Assertion: weak sanity only ({@code bytes_per_op >= 0}). This is a + * measurement tool, not a pass/fail gate — exact numbers are + * machine/JDK-dependent. + */ +@EnabledIfSystemProperty(named = "vespera.bench", matches = "true") +class AllocationBenchTest { + + private static final int WARMUP = 5_000; + private static final int MEASURE = 20_000; + private static final Map HEADERS = Map.of("accept", "application/json"); + + @BeforeAll + static void setUp() { + VesperaBridge.init("rust_jni_demo"); + } + + private static final class CountingOutputStream extends OutputStream { + long count; + + @Override + public void write(int b) { + count++; + } + + @Override + public void write(byte[] b, int off, int len) { + count += len; + } + } + + // --- Mode implementations: kept byte-for-byte equivalent to + // SmallRequestLatencyBenchTest so the latency and allocation + // numbers describe the same code path. --- + + private static int syncOnce() { + byte[] wire = VesperaBridge.encodeRequest(null, "GET", "/health", null, HEADERS, null); + return VesperaBridge.decodeResponse(VesperaBridge.dispatchBytes(wire)).status(); + } + + private static int directOnce() { + ByteBuffer resp = + VesperaBridge.dispatchDirectPooled(null, "GET", "/health", null, HEADERS, null, true); + byte[] out = new byte[resp.remaining()]; + resp.get(out); + return VesperaBridge.decodeResponse(out).status(); + } + + private static int streamingOnce() throws IOException { + byte[] wireHeader = VesperaBridge.encodeRequestHeader("GET", "/health", null, HEADERS); + CountingOutputStream sink = new CountingOutputStream(); + int[] status = new int[1]; + VesperaBridge.dispatchFullStreamingWithHeader( + wireHeader, + headerBytes -> status[0] = VesperaBridge.decodeResponse(headerBytes).status(), + new ByteArrayInputStream(new byte[0]), + sink); + return status[0]; + } + + private static int asyncOnce() { + byte[] wire = VesperaBridge.encodeRequest(null, "GET", "/health", null, HEADERS, null); + CompletableFuture future = new CompletableFuture<>(); + VesperaBridge.dispatchAsync(future, wire); + try { + byte[] resp = future.get(30, TimeUnit.SECONDS); + return VesperaBridge.decodeResponse(resp).status(); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + throw new RuntimeException(e); + } + } + + private static int responseStreamingOnce() { + byte[] wire = VesperaBridge.encodeRequest(null, "GET", "/health", null, HEADERS, null); + CountingOutputStream sink = new CountingOutputStream(); + int[] status = new int[1]; + VesperaBridge.dispatchStreamingWithHeader( + wire, + headerBytes -> status[0] = VesperaBridge.decodeResponse(headerBytes).status(), + sink); + return status[0]; + } + + private interface Op { + int run() throws IOException; + } + + /** + * Measure bytes allocated by the calling thread across MEASURE + * iterations. Returns bytes/op (integer). The loop body contains no + * Java allocations besides the {@code long} blackhole and what the + * dispatch helpers themselves do — so the per-op number describes the + * dispatch path's calling-thread allocation footprint. + */ + private static long measureAlloc(String mode, Op op, ThreadMXBean tmx) throws IOException { + long tid = Thread.currentThread().getId(); + + // Warmup — let JIT settle, TLS pools fill, classes load. + for (int i = 0; i < WARMUP; i++) { + if (op.run() != 200) { + throw new IllegalStateException(mode + " warmup non-200"); + } + } + + long blackhole = 0; + long allocBefore = tmx.getThreadAllocatedBytes(tid); + for (int i = 0; i < MEASURE; i++) { + blackhole += op.run(); + } + long allocAfter = tmx.getThreadAllocatedBytes(tid); + + long delta = allocAfter - allocBefore; + long bytesPerOp = delta / MEASURE; + + System.out.printf( + "VESPERA_ALLOC %s bytes_per_op=%d (total_delta=%d iters=%d blackhole=%d)%n", + mode, bytesPerOp, delta, MEASURE, blackhole); + + if (bytesPerOp < 0) { + throw new AssertionError( + mode + " bytes_per_op<0 (delta=" + delta + " iters=" + MEASURE + ")"); + } + return bytesPerOp; + } + + @Test + void allocationPerDispatchByMode() throws IOException { + java.lang.management.ThreadMXBean base = ManagementFactory.getThreadMXBean(); + Assumptions.assumeTrue( + base instanceof ThreadMXBean, + "platform ThreadMXBean is not com.sun.management.ThreadMXBean — non-HotSpot JVM?"); + ThreadMXBean tmx = (ThreadMXBean) base; + Assumptions.assumeTrue( + tmx.isThreadAllocatedMemorySupported(), + "ThreadMXBean.isThreadAllocatedMemorySupported()==false on this JVM"); + if (!tmx.isThreadAllocatedMemoryEnabled()) { + tmx.setThreadAllocatedMemoryEnabled(true); + } + + long sync = measureAlloc("sync_dispatch_bytes", AllocationBenchTest::syncOnce, tmx); + long direct = measureAlloc("direct_pooled", AllocationBenchTest::directOnce, tmx); + long respStreaming = + measureAlloc( + "response_streaming_only", + AllocationBenchTest::responseStreamingOnce, + tmx); + long streaming = + measureAlloc( + "bidirectional_streaming", + AllocationBenchTest::streamingOnce, + tmx); + long async = + measureAlloc( + "async_completable_future", + AllocationBenchTest::asyncOnce, + tmx); + + System.out.printf( + "VESPERA_ALLOC summary sync=%d direct=%d resp_streaming=%d bidi_streaming=%d" + + " async_caller_side=%d (async completion lands on a Rust Tokio worker" + + " thread — not measured here)%n", + sync, direct, respStreaming, streaming, async); + } +} diff --git a/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/AsyncDispatchExceptionHygieneTest.java b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/AsyncDispatchExceptionHygieneTest.java new file mode 100644 index 00000000..2757ea31 --- /dev/null +++ b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/AsyncDispatchExceptionHygieneTest.java @@ -0,0 +1,59 @@ +package kr.go.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.devfive.vespera.bridge.VesperaBridge; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class AsyncDispatchExceptionHygieneTest { + private static final Map HEADERS = Map.of("accept", "application/json"); + private static final int TIMEOUT_SECONDS = 10; + + @BeforeAll + static void setUp() { + System.setProperty("vespera.runtime.workerThreads", "1"); + VesperaBridge.init("rust_jni_demo"); + } + + @Test + void throwingFutureCompleteDoesNotPoisonNextAsyncCompletion() throws Exception { + poisonAsyncCompletion(); + + CompletableFuture healthy = new CompletableFuture<>(); + VesperaBridge.dispatchAsync(healthy, healthRequest()); + + byte[] wireResponse = healthy.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertEquals(200, VesperaBridge.decodeResponse(wireResponse).status()); + } + + private static void poisonAsyncCompletion() throws InterruptedException { + CountDownLatch completeCalled = new CountDownLatch(1); + AtomicInteger completeCalls = new AtomicInteger(); + CompletableFuture throwingFuture = new CompletableFuture<>() { + @Override + public boolean complete(byte[] value) { + completeCalls.incrementAndGet(); + completeCalled.countDown(); + throw new RuntimeException("intentional complete() failure"); + } + }; + + VesperaBridge.dispatchAsync(throwingFuture, healthRequest()); + + assertTrue( + completeCalled.await(TIMEOUT_SECONDS, TimeUnit.SECONDS), + "poison future complete() must be invoked"); + assertEquals(1, completeCalls.get(), "poison future complete() call count"); + } + + private static byte[] healthRequest() { + return VesperaBridge.encodeRequest(null, "GET", "/health", null, HEADERS, null); + } +} diff --git a/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/ConcurrencyBenchTest.java b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/ConcurrencyBenchTest.java new file mode 100644 index 00000000..02d6c5b2 --- /dev/null +++ b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/ConcurrencyBenchTest.java @@ -0,0 +1,147 @@ +package kr.go.demo; + +import com.devfive.vespera.bridge.VesperaBridge; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +/** E2E JNI concurrency throughput benchmark gated behind {@code -Dvespera.bench=true}. */ +@EnabledIfSystemProperty(named = "vespera.bench", matches = "true") +class ConcurrencyBenchTest { + + private static final int[] THREAD_COUNTS = {1, 2, 4, 8, 16}; + private static final int WARMUP_SECONDS = 1; + private static final int MEASURE_SECONDS = 3; + private static final Map HEADERS = Map.of("accept", "application/json"); + + @BeforeAll + static void setUp() { + VesperaBridge.init("rust_jni_demo"); + } + + // Mode implementations: intentionally equivalent to SmallRequestLatencyBenchTest + // so latency, allocation, and concurrency numbers describe the same code path. + private static int syncOnce() { + byte[] wire = VesperaBridge.encodeRequest(null, "GET", "/health", null, HEADERS, null); + return VesperaBridge.decodeResponse(VesperaBridge.dispatchBytes(wire)).status(); + } + + private static int directOnce() { + ByteBuffer resp = + VesperaBridge.dispatchDirectPooled(null, "GET", "/health", null, HEADERS, null, true); + // Consume like the controller does: header region must be parsed. + byte[] out = new byte[resp.remaining()]; + resp.get(out); + return VesperaBridge.decodeResponse(out).status(); + } + + private interface Op { + int run() throws IOException; + } + + private record Result(long totalOps, double opsPerSecond) {} + + private static Result measureConcurrency(String mode, Op op, int threads) throws Exception { + CountDownLatch ready = new CountDownLatch(threads); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threads); + AtomicReference failure = new AtomicReference<>(); + long[] counts = new long[threads]; + + for (int i = 0; i < threads; i++) { + int threadIndex = i; + Thread worker = + new Thread( + () -> { + try { + ready.countDown(); + start.await(); + + long warmupUntil = + System.nanoTime() + + TimeUnit.SECONDS.toNanos(WARMUP_SECONDS); + while (System.nanoTime() < warmupUntil) { + if (op.run() != 200) { + throw new IllegalStateException(mode + " warmup non-200"); + } + } + + long measured = 0; + long measureUntil = + System.nanoTime() + + TimeUnit.SECONDS.toNanos(MEASURE_SECONDS); + while (System.nanoTime() < measureUntil) { + if (op.run() != 200) { + throw new IllegalStateException(mode + " measure non-200"); + } + measured++; + } + counts[threadIndex] = measured; + } catch (Throwable t) { + failure.compareAndSet(null, t); + } finally { + done.countDown(); + } + }, + "vespera-conc-" + mode + "-" + threads + "-" + i); + worker.start(); + } + + if (!ready.await(30, TimeUnit.SECONDS)) { + throw new AssertionError(mode + " workers did not become ready"); + } + start.countDown(); + long timeout = WARMUP_SECONDS + MEASURE_SECONDS + 30L; + if (!done.await(timeout, TimeUnit.SECONDS)) { + throw new AssertionError(mode + " workers did not finish within timeout"); + } + + Throwable t = failure.get(); + if (t instanceof Exception) { + throw (Exception) t; + } + if (t instanceof Error) { + throw (Error) t; + } + if (t != null) { + throw new RuntimeException(t); + } + + long totalOps = 0; + for (long count : counts) { + totalOps += count; + } + double opsPerSecond = totalOps / (double) MEASURE_SECONDS; + return new Result(totalOps, opsPerSecond); + } + + private static void measureMode(String mode, Op op) throws Exception { + double baseline = 0.0; + for (int threads : THREAD_COUNTS) { + Result result = measureConcurrency(mode, op, threads); + if (threads == 1) { + baseline = result.opsPerSecond(); + } + double scalingEfficiency = result.opsPerSecond() / (threads * baseline) * 100.0; + System.out.printf( + "VESPERA_CONC %s threads=%d ops_per_sec=%.0f scaling_eff=%.1f total_ops=%d%n", + mode, threads, result.opsPerSecond(), scalingEfficiency, result.totalOps()); + } + } + + @Test + void concurrencyThroughputByMode() throws Exception { + int logicalCpus = Runtime.getRuntime().availableProcessors(); + System.out.printf( + "VESPERA_CONC cpus logical=%d warmup_seconds=%d measure_seconds=%d%n", + logicalCpus, WARMUP_SECONDS, MEASURE_SECONDS); + measureMode("sync_dispatch_bytes", ConcurrencyBenchTest::syncOnce); + measureMode("direct_pooled", ConcurrencyBenchTest::directOnce); + } +} diff --git a/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/DispatchDirectE2ETest.java b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/DispatchDirectE2ETest.java new file mode 100644 index 00000000..c7206618 --- /dev/null +++ b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/DispatchDirectE2ETest.java @@ -0,0 +1,244 @@ +package kr.go.demo; + +import com.devfive.vespera.bridge.VesperaBridge; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.util.Map; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * End-to-end tests for the DirectByteBuffer dispatch path — loads the + * real {@code rust_jni_demo} cdylib (bundled into test resources by the + * vespera Gradle plugin) and proves {@code dispatchDirect*} produces + * byte-identical wire responses to {@code dispatchBytes}. + * + *

{@code /echo} round-trips the request body verbatim, so request + * size == response body size — convenient for exercising the pooled + * out-buffer growth (64 KiB initial) and the overflow protocol. + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class DispatchDirectE2ETest { + + @BeforeAll + static void loadNative() { + VesperaBridge.init("rust_jni_demo"); + } + + private static byte[] echoWire(byte[] body) { + return VesperaBridge.encodeRequest( + "POST", "/echo", null, + Map.of("content-type", "application/octet-stream"), + body); + } + + private static byte[] randomBody(int size, long seed) { + byte[] body = new byte[size]; + new Random(seed).nextBytes(body); + return body; + } + + private static byte[] toArray(ByteBuffer view) { + byte[] out = new byte[view.remaining()]; + view.get(out); + return out; + } + + private static byte[] sha256(byte[] data) throws Exception { + return MessageDigest.getInstance("SHA-256").digest(data); + } + + /** + * The DIRECT response must be semantically identical to the + * dispatchBytes response: same status, same headers, SHA256-equal + * body. (Raw wire bytes are NOT compared — the wire header JSON + * serialises a Rust HashMap whose key order is intentionally + * unspecified per response.) + */ + private static void assertDirectMatchesBytes(int bodySize, long seed) throws Exception { + byte[] wire = echoWire(randomBody(bodySize, seed)); + + VesperaBridge.DecodedResponse viaBytes = + VesperaBridge.decodeResponse(VesperaBridge.dispatchBytes(wire)); + VesperaBridge.DecodedResponse viaDirect = + VesperaBridge.decodeResponse( + toArray(VesperaBridge.dispatchDirectPooled(wire, true))); + + assertEquals(200, viaDirect.status()); + assertEquals(viaBytes.status(), viaDirect.status(), "status"); + assertEquals(viaBytes.headers(), viaDirect.headers(), "headers"); + assertEquals(bodySize, viaDirect.body().remaining(), "body length"); + assertArrayEquals(sha256(viaBytes.bodyBytes()), sha256(viaDirect.bodyBytes()), + "body must be byte-identical for size " + bodySize); + } + + @Test + @Order(1) + void tinyBodyFitsInitialBuffer() throws Exception { + assertDirectMatchesBytes(1024, 1); + } + + @Test + @Order(2) + void mediumBodyTriggersOutBufferGrowth() throws Exception { + // 100 KiB response > 64 KiB initial out buffer → overflow → + // grow → re-dispatch (retryOnOverflow=true; /echo is safe). + assertDirectMatchesBytes(100 * 1024, 2); + } + + @Test + @Order(3) + void largeBodyWithinAxumLimit() throws Exception { + // 1.5 MiB — within axum's 2 MiB DefaultBodyLimit and the + // 4 MiB pool cap. + assertDirectMatchesBytes(1536 * 1024, 3); + } + + @Test + @Order(4) + void overflowWithoutRetryThrowsWithExactRequiredSize() { + byte[] body = randomBody(100 * 1024, 4); + byte[] wire = echoWire(body); + // Fresh thread → fresh 64 KiB pooled out buffer, guaranteed + // smaller than the ~100 KiB wire response. + VesperaBridge.BufferTooSmallException e = assertThrows( + VesperaBridge.BufferTooSmallException.class, + () -> runOnFreshThread(() -> + VesperaBridge.dispatchDirectPooled(wire, false))); + assertTrue(e.requiredSize() > 100 * 1024, + "required size must cover header + body, got " + e.requiredSize()); + } + + @Test + @Order(5) + void rawDispatchDirectHonoursExplicitInLen() throws Exception { + byte[] body = randomBody(512, 5); + byte[] wire = echoWire(body); + + // Oversized in buffer with garbage after the wire bytes — + // explicit inLen must make the tail invisible to Rust. + ByteBuffer in = ByteBuffer.allocateDirect(wire.length + 1024); + in.put(wire); + in.put(new byte[1024]); // garbage tail + ByteBuffer out = ByteBuffer.allocateDirect(64 * 1024); + + int n = VesperaBridge.dispatchDirect(in, wire.length, out); + assertTrue(n > 0, "expected success, got " + n); + + byte[] direct = new byte[n]; + out.get(0, direct); + + VesperaBridge.DecodedResponse viaDirect = VesperaBridge.decodeResponse(direct); + VesperaBridge.DecodedResponse viaBytes = + VesperaBridge.decodeResponse(VesperaBridge.dispatchBytes(wire)); + assertEquals(viaBytes.status(), viaDirect.status(), "status"); + assertEquals(viaBytes.body().remaining(), viaDirect.body().remaining(), + "body length — a mismatch means the garbage tail leaked past inLen"); + assertArrayEquals(viaBytes.bodyBytes(), viaDirect.bodyBytes(), "body bytes"); + // Map equality — wire JSON key order is unspecified. + assertEquals(viaBytes.headers(), viaDirect.headers(), "headers"); + } + + @Test + @Order(6) + void encodeIntoOverloadMatchesByteArrayOverload() throws Exception { + // The encode-into overload must produce a semantically identical + // response to the byte[]-wire overload for the same request. + byte[] body = randomBody(100 * 1024, 6); + Map headers = Map.of("content-type", "application/octet-stream"); + + VesperaBridge.DecodedResponse viaWire = VesperaBridge.decodeResponse( + toArray(VesperaBridge.dispatchDirectPooled(echoWire(body), true))); + VesperaBridge.DecodedResponse viaEncodeInto = VesperaBridge.decodeResponse( + toArray(VesperaBridge.dispatchDirectPooled( + null, "POST", "/echo", null, headers, body, true))); + + assertEquals(viaWire.status(), viaEncodeInto.status(), "status"); + assertEquals(viaWire.headers(), viaEncodeInto.headers(), "headers"); + assertArrayEquals(sha256(viaWire.bodyBytes()), sha256(viaEncodeInto.bodyBytes()), "body"); + } + + @Test + @Order(7) + void microBenchmarkDirectVsBytes() throws Exception { + System.out.println( + "== dispatchBytes vs dispatchDirectPooled(wire) vs dispatchDirectPooled(encode-into) =="); + Map headers = Map.of("content-type", "application/octet-stream"); + for (int size : new int[] {1024, 64 * 1024, 1536 * 1024}) { + byte[] body = randomBody(size, size); + byte[] wire = echoWire(body); + int iterations = size >= 1024 * 1024 ? 200 : 1000; + + // Warm-up all paths (JIT + pool growth). + for (int i = 0; i < 50; i++) { + VesperaBridge.dispatchBytes(wire); + VesperaBridge.dispatchDirectPooled(wire, true); + VesperaBridge.dispatchDirectPooled(null, "POST", "/echo", null, headers, body, true); + } + + // FAIR comparison: real callers encode per request, so the + // byte[]-based paths pay encodeRequest inside the loop too. + long t0 = System.nanoTime(); + for (int i = 0; i < iterations; i++) { + VesperaBridge.dispatchBytes( + VesperaBridge.encodeRequest(null, "POST", "/echo", null, headers, body)); + } + long bytesNs = (System.nanoTime() - t0) / iterations; + + t0 = System.nanoTime(); + for (int i = 0; i < iterations; i++) { + VesperaBridge.dispatchDirectPooled( + VesperaBridge.encodeRequest(null, "POST", "/echo", null, headers, body), + true); + } + long directNs = (System.nanoTime() - t0) / iterations; + + t0 = System.nanoTime(); + for (int i = 0; i < iterations; i++) { + VesperaBridge.dispatchDirectPooled(null, "POST", "/echo", null, headers, body, true); + } + long encodeIntoNs = (System.nanoTime() - t0) / iterations; + + System.out.printf( + "body=%8d B bytes=%9d ns direct(wire)=%9d ns direct(encodeInto)=%9d ns " + + "vsBytes=%.2fx vsWire=%.2fx%n", + size, bytesNs, directNs, encodeIntoNs, + (double) bytesNs / encodeIntoNs, (double) directNs / encodeIntoNs); + } + } + + /** Run on a fresh thread so the ThreadLocal pool starts at 64 KiB. */ + private static void runOnFreshThread(Runnable action) throws E { + Throwable[] thrown = new Throwable[1]; + Thread t = new Thread(() -> { + try { + action.run(); + } catch (Throwable e) { + thrown[0] = e; + } + }); + t.start(); + try { + t.join(); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(ie); + } + if (thrown[0] instanceof RuntimeException re) { + throw re; + } + if (thrown[0] != null) { + throw new IllegalStateException(thrown[0]); + } + } +} diff --git a/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/JfrAllocationProfileLoadTest.java b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/JfrAllocationProfileLoadTest.java new file mode 100644 index 00000000..9ac003df --- /dev/null +++ b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/JfrAllocationProfileLoadTest.java @@ -0,0 +1,79 @@ +package kr.go.demo; + +import com.devfive.vespera.bridge.VesperaBridge; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +/** Sustained single-threaded JNI load for allocation profiling under JFR. */ +@EnabledIfSystemProperty(named = "vespera.bench", matches = "true") +class JfrAllocationProfileLoadTest { + + private static final int WARMUP_SECONDS = 1; + private static final int LOAD_SECONDS = 10; + private static final Map HEADERS = Map.of("accept", "application/json"); + + @BeforeAll + static void setUp() { + VesperaBridge.init("rust_jni_demo"); + } + + // Mode implementations: intentionally equivalent to SmallRequestLatencyBenchTest + // so JFR samples map to the same helper paths as the latency/allocation benches. + private static int syncOnce() { + byte[] wire = VesperaBridge.encodeRequest(null, "GET", "/health", null, HEADERS, null); + return VesperaBridge.decodeResponse(VesperaBridge.dispatchBytes(wire)).status(); + } + + private static int directOnce() { + ByteBuffer resp = + VesperaBridge.dispatchDirectPooled(null, "GET", "/health", null, HEADERS, null, true); + byte[] out = new byte[resp.remaining()]; + resp.get(out); + return VesperaBridge.decodeResponse(out).status(); + } + + private interface Op { + int run() throws IOException; + } + + private static void warmup(String mode, Op op) throws IOException { + long until = System.nanoTime() + TimeUnit.SECONDS.toNanos(WARMUP_SECONDS); + while (System.nanoTime() < until) { + if (op.run() != 200) { + throw new IllegalStateException(mode + " warmup non-200"); + } + } + } + + private static void load(String mode, Op op) throws IOException { + warmup(mode, op); + + long ops = 0; + long started = System.nanoTime(); + long until = started + TimeUnit.SECONDS.toNanos(LOAD_SECONDS); + while (System.nanoTime() < until) { + if (op.run() != 200) { + throw new IllegalStateException(mode + " load non-200"); + } + ops++; + } + double seconds = (System.nanoTime() - started) / 1_000_000_000.0; + System.out.printf( + "VESPERA_JFR_LOAD %s ops_per_sec=%.0f total_ops=%d seconds=%.2f%n", + mode, ops / seconds, ops, seconds); + } + + @Test + void sustainedSyncAndDirectLoad() throws IOException { + System.out.printf( + "VESPERA_JFR_LOAD warmup_seconds=%d load_seconds_per_mode=%d%n", + WARMUP_SECONDS, LOAD_SECONDS); + load("sync_dispatch_bytes", JfrAllocationProfileLoadTest::syncOnce); + load("direct_pooled", JfrAllocationProfileLoadTest::directOnce); + } +} diff --git a/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/SmallRequestLatencyBenchTest.java b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/SmallRequestLatencyBenchTest.java new file mode 100644 index 00000000..1ab79c33 --- /dev/null +++ b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/SmallRequestLatencyBenchTest.java @@ -0,0 +1,139 @@ +package kr.go.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.devfive.vespera.bridge.VesperaBridge; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +/** E2E JNI latency benchmark gated behind {@code -Dvespera.bench=true}. */ +@EnabledIfSystemProperty(named = "vespera.bench", matches = "true") +class SmallRequestLatencyBenchTest { + + private static final int WARMUP = 20_000; + private static final int ITERS = 100_000; + private static final Map HEADERS = Map.of("accept", "application/json"); + + @BeforeAll + static void setUp() { + VesperaBridge.init("rust_jni_demo"); + } + + private static final class CountingOutputStream extends OutputStream { + long count; + + @Override + public void write(int b) { + count++; + } + + @Override + public void write(byte[] b, int off, int len) { + count += len; + } + } + + private static int syncOnce() { + byte[] wire = VesperaBridge.encodeRequest(null, "GET", "/health", null, HEADERS, null); + return VesperaBridge.decodeResponse(VesperaBridge.dispatchBytes(wire)).status(); + } + + private static int directOnce() { + ByteBuffer resp = + VesperaBridge.dispatchDirectPooled(null, "GET", "/health", null, HEADERS, null, true); + // Consume like the controller does: header region must be parsed. + byte[] out = new byte[resp.remaining()]; + resp.get(out); + return VesperaBridge.decodeResponse(out).status(); + } + + private static int streamingOnce() throws IOException { + byte[] wireHeader = VesperaBridge.encodeRequestHeader("GET", "/health", null, HEADERS); + CountingOutputStream sink = new CountingOutputStream(); + int[] status = new int[1]; + VesperaBridge.dispatchFullStreamingWithHeader( + wireHeader, + headerBytes -> status[0] = VesperaBridge.decodeResponse(headerBytes).status(), + new ByteArrayInputStream(new byte[0]), + sink); + return status[0]; + } + + private static int asyncOnce() { + byte[] wire = VesperaBridge.encodeRequest(null, "GET", "/health", null, HEADERS, null); + CompletableFuture future = new CompletableFuture<>(); + VesperaBridge.dispatchAsync(future, wire); + try { + byte[] resp = future.get(30, TimeUnit.SECONDS); + return VesperaBridge.decodeResponse(resp).status(); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + throw new RuntimeException(e); + } + } + + /** Response-streaming only — no request pull thread (empty body inline). */ + private static int responseStreamingOnce() { + byte[] wire = VesperaBridge.encodeRequest(null, "GET", "/health", null, HEADERS, null); + CountingOutputStream sink = new CountingOutputStream(); + int[] status = new int[1]; + VesperaBridge.dispatchStreamingWithHeader( + wire, + headerBytes -> status[0] = VesperaBridge.decodeResponse(headerBytes).status(), + sink); + return status[0]; + } + + private interface Op { + int run() throws IOException; + } + + private static long measure(String name, Op op) throws IOException { + for (int i = 0; i < WARMUP; i++) { + assertEquals(200, op.run(), name + " warmup status"); + } + long blackhole = 0; + long t0 = System.nanoTime(); + for (int i = 0; i < ITERS; i++) { + blackhole += op.run(); + } + long nsPerOp = (System.nanoTime() - t0) / ITERS; + System.out.printf( + "VESPERA_BENCH small_request mode=%s ns_per_op=%d (blackhole %d)%n", + name, nsPerOp, blackhole); + return nsPerOp; + } + + @Test + void smallRequestLatencyByMode() throws IOException { + long sync = measure("sync_dispatch_bytes", SmallRequestLatencyBenchTest::syncOnce); + long direct = measure("direct_pooled", SmallRequestLatencyBenchTest::directOnce); + long respStreaming = + measure( + "response_streaming_only", + SmallRequestLatencyBenchTest::responseStreamingOnce); + long streaming = + measure("bidirectional_streaming", SmallRequestLatencyBenchTest::streamingOnce); + long async = + measure( + "async_completable_future", + SmallRequestLatencyBenchTest::asyncOnce); + System.out.printf( + "VESPERA_BENCH summary direct_vs_streaming=%.2fx direct_vs_sync=%.2fx" + + " resp_only_vs_bidi=%.2fx async_vs_sync=%.2fx async_vs_direct=%.2fx%n", + (double) streaming / direct, + (double) sync / direct, + (double) streaming / respStreaming, + (double) async / sync, + (double) async / direct); + } +} diff --git a/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/StreamingClosureStressTest.java b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/StreamingClosureStressTest.java new file mode 100644 index 00000000..347466a5 --- /dev/null +++ b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/StreamingClosureStressTest.java @@ -0,0 +1,453 @@ +package kr.go.demo; + +import com.devfive.vespera.bridge.VesperaBridge; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * SIGSEGV gate for the cached + * {@code call_method_unchecked} JNI fast path landed in + * {@code crates/vespera_jni/src/streaming_closures.rs}. + * + *

Stress-tests the four cached Java {@code JMethodID}s the new + * code exercises on every streaming/async dispatch: + *

    + *
  • {@code java/io/InputStream.read([B)I} — pulled by + * {@link VesperaBridge#dispatchFullStreaming}
  • + *
  • {@code java/io/OutputStream.write([BII)V} — pushed by + * {@code dispatchFullStreaming} and + * {@code dispatchStreamingWithHeader}
  • + *
  • {@code java/util/function/Consumer.accept(Ljava/lang/Object;)V} — + * header callback fired by {@code dispatchStreamingWithHeader} + * before the first body byte reaches the {@code OutputStream}
  • + *
  • {@code java/util/concurrent/CompletableFuture.complete(Ljava/lang/Object;)Z} — + * async completion path used by {@code dispatchAsync}
  • + *
+ * + *

If any of those cached method IDs resolves the wrong + * class / signature / vtable slot, calling them through + * {@code call_method_unchecked} will SIGSEGV the test JVM — and the + * abnormal Gradle worker shutdown IS the test failure signal. + * The prior E2E run never exercised the cached path because + * {@code StreamingThroughputBenchTest} is gated behind + * {@code -Dvespera.bench=true} (see {@code @EnabledIfSystemProperty}). + * This test runs unconditionally as part of the normal {@code test} + * task to lock that gap. + * + *

Verification per iteration: + *

    + *
  • Random 1 MiB body driven by a single shared {@link Random} + * seed ({@code SEED}) for deterministic replay.
  • + *
  • SHA-256 of the body that left the JVM == SHA-256 of the body + * that came back through {@code /echo/stream}.
  • + *
  • For the bidirectional path: {@code InputStream.read} fired + * multiple times (multi-chunk pull) AND {@code OutputStream.write} + * fired multiple times (multi-chunk push), proving the cached + * method IDs were called repeatedly per dispatch. With the + * default 256 KiB streaming chunk size and a 1 MiB payload the + * Rust side performs ~4 pulls + 1 EOF read and ~4 pushes + * per iteration. (Assertions only require {@code > 1} — they + * are robust to chunk-size tuning down to 4 KiB or up to + * 512 KiB; below the multi-chunk threshold the test would need + * to bump the payload.)
  • + *
  • For the header-streaming path: {@code Consumer.accept} fires + * exactly once and before the + * first {@code OutputStream.write}; header decodes as wire JSON + * with status 200.
  • + *
  • For the async path: {@code CompletableFuture} completes + * successfully with a valid wire response (status 200, body + * matches by SHA-256).
  • + *
+ * + *

Iteration budget — sized to keep wall-clock for + * the whole class comfortably under ~90s on a normal developer machine + * while pushing the cached paths thousands of times: + *

    + *
  • {@code dispatchFullStreaming}: {@value #BIDI_ITERATIONS} × 1 MiB + * → ~4 000 cached {@code InputStream.read} calls + ~4 000 + * cached {@code OutputStream.write} calls (with the 256 KiB + * default chunk; was ~16 000 each at the prior 64 KiB default)
  • + *
  • {@code dispatchStreamingWithHeader}: {@value #HEADER_STREAMING_ITERATIONS} + * × 1 MiB → ~{@value #HEADER_STREAMING_ITERATIONS} cached + * {@code Consumer.accept} calls + ~2 000 cached + * {@code OutputStream.write} calls
  • + *
  • {@code dispatchAsync}: {@value #ASYNC_ITERATIONS} × 1 MiB → + * {@value #ASYNC_ITERATIONS} cached + * {@code CompletableFuture.complete} calls
  • + *
+ * + *

If a slower machine pushes the run over ~90s, drop these constants + * to 500 / 250 / 250 — the cached path is exercised plenty even at the + * lower budget; the higher budget is just a wider net for races. + * Per-test wall-clock is printed to stdout so reductions are + * data-driven. + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class StreamingClosureStressTest { + + /** Shared seed so any failure replays deterministically. */ + private static final long SEED = 0xCAFEBABEL; + + /** 1 MiB — well above the default 256 KiB streaming chunk so each + * dispatch pulls/pushes ~4 chunks, exercising the cached path + * several times per call. Assertions only require {@code > 1} + * chunk so the test stays valid across the supported chunk-size + * range (4 KiB – 8 MiB). */ + private static final int PAYLOAD_BYTES = 1024 * 1024; + + private static final int BIDI_ITERATIONS = 1000; + private static final int HEADER_STREAMING_ITERATIONS = 500; + private static final int ASYNC_ITERATIONS = 500; + + private static final Map ECHO_HEADERS = + Map.of("content-type", "application/octet-stream"); + + /** Bound the async wait so a SIGSEGV-induced hang fails fast + * instead of stalling the Gradle worker until its own timeout. */ + private static final long ASYNC_TIMEOUT_SECONDS = 30; + + @BeforeAll + static void loadNative() { + VesperaBridge.init("rust_jni_demo"); + } + + private static byte[] sha256(byte[] data) { + try { + return MessageDigest.getInstance("SHA-256").digest(data); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } + + private static byte[] randomPayload(Random rng) { + byte[] body = new byte[PAYLOAD_BYTES]; + rng.nextBytes(body); + return body; + } + + /** Counts {@code read(byte[])} invocations — the exact signature + * cached by {@code streaming_closures::call_input_stream_read}. */ + private static final class CountingInputStream extends InputStream { + private final InputStream delegate; + int readArrayCalls; + + CountingInputStream(InputStream delegate) { + this.delegate = delegate; + } + + @Override + public int read() throws IOException { + // Not on the cached path — but counted defensively in case + // the Rust side ever falls back to single-byte reads. + return delegate.read(); + } + + @Override + public int read(byte[] b) throws IOException { + readArrayCalls++; + return delegate.read(b); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + // Not on the cached path — Rust calls the no-offset overload. + return delegate.read(b, off, len); + } + } + + /** Counts {@code write(byte[], int, int)} invocations — the exact + * signature cached by + * {@code streaming_closures::call_output_stream_write}. */ + private static final class CountingByteSink extends OutputStream { + final ByteArrayOutputStream buf = new ByteArrayOutputStream(PAYLOAD_BYTES); + int writeRegionCalls; + + @Override + public void write(int b) { + // Not on the cached path; included for completeness. + buf.write(b); + } + + @Override + public void write(byte[] b, int off, int len) { + writeRegionCalls++; + buf.write(b, off, len); + } + + byte[] toBytes() { + return buf.toByteArray(); + } + + int size() { + return buf.size(); + } + } + + /** + * Exercises cached {@code InputStream.read([B)I} AND cached + * {@code OutputStream.write([BII)V} repeatedly per dispatch. + */ + @Test + @Order(1) + void bidirectionalStreaming_cachedReadAndWrite() throws Exception { + Random rng = new Random(SEED); + byte[] wireHeader = VesperaBridge.encodeRequestHeader( + "POST", "/echo/stream", null, ECHO_HEADERS); + + long totalReads = 0; + long totalWrites = 0; + long t0 = System.nanoTime(); + + for (int i = 0; i < BIDI_ITERATIONS; i++) { + byte[] payload = randomPayload(rng); + byte[] expectedSha = sha256(payload); + + CountingInputStream src = new CountingInputStream(new ByteArrayInputStream(payload)); + CountingByteSink sink = new CountingByteSink(); + + byte[] respHeader = + VesperaBridge.dispatchFullStreaming(wireHeader, src, sink); + VesperaBridge.DecodedResponse resp = VesperaBridge.decodeResponse(respHeader); + + assertEquals(200, resp.status(), + "iter " + i + ": echo must succeed (status)"); + assertEquals(PAYLOAD_BYTES, sink.size(), + "iter " + i + ": echoed byte count"); + assertArrayEquals(expectedSha, sha256(sink.toBytes()), + "iter " + i + ": SHA-256 round-trip"); + assertTrue(src.readArrayCalls > 1, + "iter " + i + ": expected multi-chunk pulls through cached" + + " InputStream.read, got " + src.readArrayCalls); + assertTrue(sink.writeRegionCalls > 1, + "iter " + i + ": expected multi-chunk pushes through cached" + + " OutputStream.write, got " + sink.writeRegionCalls); + + totalReads += src.readArrayCalls; + totalWrites += sink.writeRegionCalls; + } + + long elapsedMs = (System.nanoTime() - t0) / 1_000_000L; + System.out.printf( + "STRESS bidi(/echo/stream): iter=%d payload=%dB elapsed=%dms" + + " cachedReads=%d cachedWrites=%d (avg/iter %.1f reads, %.1f writes)%n", + BIDI_ITERATIONS, PAYLOAD_BYTES, elapsedMs, + totalReads, totalWrites, + (double) totalReads / BIDI_ITERATIONS, + (double) totalWrites / BIDI_ITERATIONS); + } + + /** + * Exercises cached {@code Consumer.accept(Ljava/lang/Object;)V} + * (once per dispatch, before any body byte) and cached + * {@code OutputStream.write([BII)V} (many times per dispatch). + */ + @Test + @Order(2) + void responseStreamingWithHeader_cachedConsumerAndWrite() throws Exception { + Random rng = new Random(SEED); + long totalHeaderCalls = 0; + long totalWrites = 0; + long t0 = System.nanoTime(); + + for (int i = 0; i < HEADER_STREAMING_ITERATIONS; i++) { + byte[] payload = randomPayload(rng); + byte[] expectedSha = sha256(payload); + + byte[] wireRequest = VesperaBridge.encodeRequest( + "POST", "/echo/stream", null, ECHO_HEADERS, payload); + + CountingByteSink sink = new CountingByteSink(); + AtomicInteger headerCalls = new AtomicInteger(); + AtomicReference headerBytesRef = new AtomicReference<>(); + // -1 sentinel; captured value MUST be 0 (no writes yet when + // the header consumer is called). + AtomicLong writesAtHeaderTime = new AtomicLong(-1); + + VesperaBridge.dispatchStreamingWithHeader( + wireRequest, + headerBytes -> { + writesAtHeaderTime.set(sink.writeRegionCalls); + // Copy because the JNI side may reuse the array. + headerBytesRef.set(headerBytes.clone()); + headerCalls.incrementAndGet(); + }, + sink); + + assertEquals(1, headerCalls.get(), + "iter " + i + ": header consumer must fire exactly once"); + assertEquals(0L, writesAtHeaderTime.get(), + "iter " + i + ": header consumer must fire BEFORE any" + + " OutputStream.write"); + byte[] hdr = headerBytesRef.get(); + assertNotNull(hdr, "iter " + i + ": header bytes captured"); + + VesperaBridge.DecodedResponse resp = VesperaBridge.decodeResponse(hdr); + assertEquals(200, resp.status(), + "iter " + i + ": wire header parses with status 200"); + assertEquals(PAYLOAD_BYTES, sink.size(), + "iter " + i + ": echoed byte count"); + assertArrayEquals(expectedSha, sha256(sink.toBytes()), + "iter " + i + ": SHA-256 round-trip"); + assertTrue(sink.writeRegionCalls > 1, + "iter " + i + ": expected multi-chunk pushes through cached" + + " OutputStream.write, got " + sink.writeRegionCalls); + + totalHeaderCalls += headerCalls.get(); + totalWrites += sink.writeRegionCalls; + } + + long elapsedMs = (System.nanoTime() - t0) / 1_000_000L; + System.out.printf( + "STRESS header-stream(/echo/stream): iter=%d payload=%dB elapsed=%dms" + + " cachedConsumerCalls=%d cachedWrites=%d (avg/iter %.1f writes)%n", + HEADER_STREAMING_ITERATIONS, PAYLOAD_BYTES, elapsedMs, + totalHeaderCalls, totalWrites, + (double) totalWrites / HEADER_STREAMING_ITERATIONS); + } + + /** + * Exercises cached + * {@code CompletableFuture.complete(Ljava/lang/Object;)Z}. + */ + @Test + @Order(3) + void asyncDispatch_cachedFutureComplete() throws Exception { + Random rng = new Random(SEED); + long t0 = System.nanoTime(); + + for (int i = 0; i < ASYNC_ITERATIONS; i++) { + byte[] payload = randomPayload(rng); + byte[] expectedSha = sha256(payload); + + byte[] wireRequest = VesperaBridge.encodeRequest( + "POST", "/echo/stream", null, ECHO_HEADERS, payload); + + CompletableFuture future = new CompletableFuture<>(); + VesperaBridge.dispatchAsync(future, wireRequest); + + byte[] wireResponse; + try { + wireResponse = future.get(ASYNC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (TimeoutException te) { + fail("iter " + i + ": dispatchAsync future did not complete within " + + ASYNC_TIMEOUT_SECONDS + "s"); + return; // unreachable; keeps the compiler happy + } + + assertNotNull(wireResponse, + "iter " + i + ": future must complete with non-null payload"); + assertTrue(future.isDone() && !future.isCompletedExceptionally(), + "iter " + i + ": future must be normally completed"); + + VesperaBridge.DecodedResponse resp = VesperaBridge.decodeResponse(wireResponse); + assertEquals(200, resp.status(), "iter " + i + ": status"); + assertEquals(PAYLOAD_BYTES, resp.body().remaining(), + "iter " + i + ": body length"); + assertArrayEquals(expectedSha, sha256(resp.bodyBytes()), + "iter " + i + ": SHA-256 round-trip"); + } + + long elapsedMs = (System.nanoTime() - t0) / 1_000_000L; + System.out.printf( + "STRESS async(/echo/stream): iter=%d payload=%dB elapsed=%dms" + + " cachedFutureCompleteCalls=%d%n", + ASYNC_ITERATIONS, PAYLOAD_BYTES, elapsedMs, ASYNC_ITERATIONS); + } + + /** + * Handler-panic fallback: {@code /echo/panic} panics before producing + * status/headers. The "header consumer invoked exactly once on every + * code path" contract requires {@code dispatchStreamingWithHeader} to + * still fire the consumer — with a wire-format {@code 500} header (the + * Rust-side {@code header_sent} fallback) — instead of leaving this + * caller hanging. Guards the JNI catch_unwind + fallback path that + * has no Rust-level unit test (it needs a real JVM). + */ + @Test + @Order(4) + void responseStreamingWithHeader_handlerPanic_firesHeaderWith500() { + byte[] wireRequest = VesperaBridge.encodeRequest( + "POST", "/echo/panic", null, ECHO_HEADERS, new byte[] {1, 2, 3}); + + CountingByteSink sink = new CountingByteSink(); + AtomicInteger headerCalls = new AtomicInteger(); + AtomicReference headerBytesRef = new AtomicReference<>(); + + VesperaBridge.dispatchStreamingWithHeader( + wireRequest, + headerBytes -> { + headerBytesRef.set(headerBytes.clone()); + headerCalls.incrementAndGet(); + }, + sink); + + assertEquals(1, headerCalls.get(), + "header consumer must fire exactly once even when the handler panics"); + byte[] hdr = headerBytesRef.get(); + assertNotNull(hdr, "header bytes must be captured on a handler panic"); + VesperaBridge.DecodedResponse resp = VesperaBridge.decodeResponse(hdr); + assertEquals(500, resp.status(), + "a panic before the header must surface as a 500 header, not a hang"); + assertEquals(0, sink.size(), + "no body should be written when the handler panics before headers"); + } + + /** + * Push failed-flag: a hostile/broken {@code OutputStream} that throws + * on every write must not hang or SIGSEGV the JVM. The Rust push + * closure latches a {@code failed} flag on the first write failure and + * turns subsequent frames into a no-op instead of repeatedly crossing + * JNI into the broken sink; the dispatch still returns the wire header. + */ + @Test + @Order(5) + void responseStreaming_outputStreamThrows_doesNotHangOrCrash() { + byte[] payload = randomPayload(new Random(SEED)); + byte[] wireRequest = VesperaBridge.encodeRequest( + "POST", "/echo/stream", null, ECHO_HEADERS, payload); + + OutputStream throwing = new OutputStream() { + @Override + public void write(int b) throws IOException { + throw new IOException("sink closed"); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + throw new IOException("sink closed"); + } + }; + + byte[] respHeader = VesperaBridge.dispatchStreaming(wireRequest, throwing); + assertNotNull(respHeader, + "dispatch must return a header even when the OutputStream throws"); + VesperaBridge.DecodedResponse resp = VesperaBridge.decodeResponse(respHeader); + assertEquals(200, resp.status(), + "the handler succeeded (200); only the JVM sink failed"); + } +} diff --git a/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/StreamingThroughputBenchTest.java b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/StreamingThroughputBenchTest.java new file mode 100644 index 00000000..b1952ab2 --- /dev/null +++ b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/StreamingThroughputBenchTest.java @@ -0,0 +1,109 @@ +package kr.go.demo; + +import com.devfive.vespera.bridge.VesperaBridge; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.Map; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * E2E streaming throughput benchmark through the REAL JNI boundary — + * measures {@code dispatchFullStreamingWithHeader} (the autoconfigured + * default dispatch mode) round-tripping a large body through the Rust + * {@code /echo} route. + * + *

The streaming chunk size is process-fixed after + * the first dispatch, so each chunk size needs its own JVM. Run via: + * + *

+ *   ./gradlew :demo-app:test --tests "*StreamingThroughputBenchTest*" \
+ *       -Dvespera.bench=true -Dvespera.streaming.chunkBytes=16384
+ * 
+ * + *

Gated behind {@code -Dvespera.bench=true} so normal test runs and + * CI skip it. + */ +@EnabledIfSystemProperty(named = "vespera.bench", matches = "true") +class StreamingThroughputBenchTest { + + private static final int PAYLOAD_BYTES = 64 * 1024 * 1024; // 64 MiB + private static final int WARMUP_ITERATIONS = 3; + private static final int MEASURE_ITERATIONS = 10; + + private static byte[] payload; + + @BeforeAll + static void setUp() { + VesperaBridge.init("rust_jni_demo"); + payload = new byte[PAYLOAD_BYTES]; + new Random(42).nextBytes(payload); + } + + /** OutputStream that counts bytes without storing them. */ + private static final class CountingOutputStream extends OutputStream { + long count; + + @Override + public void write(int b) { + count++; + } + + @Override + public void write(byte[] b, int off, int len) { + count += len; + } + } + + private static long roundTripOnce() throws IOException { + byte[] wireHeader = VesperaBridge.encodeRequestHeader( + "POST", "/echo/stream", null, + Map.of("content-type", "application/octet-stream")); + CountingOutputStream sink = new CountingOutputStream(); + int[] status = new int[1]; + VesperaBridge.dispatchFullStreamingWithHeader( + wireHeader, + headerBytes -> status[0] = VesperaBridge.decodeResponse(headerBytes).status(), + new ByteArrayInputStream(payload), + sink); + assertEquals(200, status[0], "echo status"); + assertEquals(PAYLOAD_BYTES, sink.count, "echoed byte count"); + return sink.count; + } + + @Test + void bidirectionalStreamingThroughput() throws IOException { + String chunkProp = System.getProperty("vespera.streaming.chunkBytes", "default(262144)"); + + for (int i = 0; i < WARMUP_ITERATIONS; i++) { + roundTripOnce(); + } + + double[] mibPerSec = new double[MEASURE_ITERATIONS]; + for (int i = 0; i < MEASURE_ITERATIONS; i++) { + long t0 = System.nanoTime(); + roundTripOnce(); + long elapsedNs = System.nanoTime() - t0; + // Bidirectional: payload travels Java→Rust AND Rust→Java. + mibPerSec[i] = (PAYLOAD_BYTES / (1024.0 * 1024.0)) / (elapsedNs / 1_000_000_000.0); + } + + double mean = 0; + for (double v : mibPerSec) mean += v; + mean /= MEASURE_ITERATIONS; + double var = 0; + for (double v : mibPerSec) var += (v - mean) * (v - mean); + double stddev = Math.sqrt(var / MEASURE_ITERATIONS); + + System.out.printf( + "VESPERA_BENCH chunkBytes=%s payload=%d MiB iterations=%d" + + " throughput=%.1f MiB/s stddev=%.1f%n", + chunkProp, PAYLOAD_BYTES / (1024 * 1024), MEASURE_ITERATIONS, mean, stddev); + } +} diff --git a/examples/rust-jni-demo/src/routes/echo.rs b/examples/rust-jni-demo/src/routes/echo.rs index 2a77340b..e4baa0de 100644 --- a/examples/rust-jni-demo/src/routes/echo.rs +++ b/examples/rust-jni-demo/src/routes/echo.rs @@ -23,3 +23,29 @@ pub async fn echo(headers: HeaderMap, body: Bytes) -> Response { .to_owned(); ([(header::CONTENT_TYPE, ct)], body).into_response() } + +/// **Streaming** echo — passes the request body stream straight +/// through as the response body without ever buffering it. Unlike +/// `/echo` (which extracts `Bytes` and is therefore subject to axum's +/// 2 MiB `DefaultBodyLimit`), this handler consumes the raw +/// [`vespera::axum::body::Body`], so multi-GiB bidirectional streams +/// can be exercised end-to-end — used by the JNI streaming throughput +/// benchmark (`StreamingThroughputBenchTest`). +#[allow(clippy::unused_async)] +#[vespera::route(post, path = "/stream", tags = ["echo"])] +pub async fn echo_stream(body: vespera::axum::body::Body) -> Response { + Response::new(body) +} + +/// Always panics — exercises the JNI "header callback exactly once" +/// contract from the Java side. When this handler panics before +/// producing status/headers, `dispatchStreamingWithHeader` / +/// `dispatchFullStreamingWithHeader` must still invoke the header +/// consumer once with a wire-format `500` header (the `header_sent` +/// fallback) rather than leaving the caller hanging. Used by +/// `StreamingClosureStressTest`'s panic-fallback e2e case. +#[allow(clippy::unused_async, clippy::panic)] +#[vespera::route(post, path = "/panic", tags = ["echo"])] +pub async fn echo_panic() -> Response { + panic!("intentional handler panic for the header-once fallback e2e test"); +} diff --git a/examples/third/Cargo.lock b/examples/third/Cargo.lock deleted file mode 100644 index eeace9b2..00000000 --- a/examples/third/Cargo.lock +++ /dev/null @@ -1,1591 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "axum" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" -dependencies = [ - "axum-core", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-example" -version = "0.1.0" -dependencies = [ - "axum", - "axum-test", - "serde", - "serde_json", - "tokio", - "vespera", -] - -[[package]] -name = "axum-test" -version = "18.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0388808c0617a886601385c0024b9d0162480a763ba371f803d87b775115400" -dependencies = [ - "anyhow", - "axum", - "bytes", - "bytesize", - "cookie", - "expect-json", - "http", - "http-body-util", - "hyper", - "hyper-util", - "mime", - "pretty_assertions", - "reserve-port", - "rust-multipart-rfc7578_2", - "serde", - "serde_json", - "serde_urlencoded", - "smallvec", - "tokio", - "tower", - "url", -] - -[[package]] -name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" - -[[package]] -name = "bumpalo" -version = "3.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" - -[[package]] -name = "bytes" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" - -[[package]] -name = "bytesize" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00f4369ba008f82b968b1acbe31715ec37bd45236fa0726605a36cc3060ea256" - -[[package]] -name = "cc" -version = "1.2.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "chrono" -version = "0.4.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "time", - "version_check", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "deranged" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "diff" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" - -[[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 = "email_address" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" -dependencies = [ - "serde", -] - -[[package]] -name = "erased-serde" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - -[[package]] -name = "expect-json" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7519e78573c950576b89eb4f4fe82aedf3a80639245afa07e3ee3d199dcdb29e" -dependencies = [ - "chrono", - "email_address", - "expect-json-macros", - "num", - "serde", - "serde_json", - "thiserror", - "typetag", - "uuid", -] - -[[package]] -name = "expect-json-macros" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bf7f5979e98460a0eb412665514594f68f366a32b85fa8d7ffb65bb1edee6a0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" - -[[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-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-core", - "futures-io", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-util" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http", - "http-body", - "hyper", - "libc", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[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.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "inventory" -version = "0.3.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" -dependencies = [ - "rustversion", -] - -[[package]] -name = "itoa" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" - -[[package]] -name = "js-sys" -version = "0.3.82" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "libc" -version = "0.2.177" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" - -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "memchr" -version = "2.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mio" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[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.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "pretty_assertions" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" -dependencies = [ - "diff", - "yansi", -] - -[[package]] -name = "proc-macro2" -version = "1.0.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[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", -] - -[[package]] -name = "rand_core" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" -dependencies = [ - "getrandom", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "reserve-port" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356" -dependencies = [ - "thiserror", -] - -[[package]] -name = "rust-multipart-rfc7578_2" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c839d037155ebc06a571e305af66ff9fd9063a6e662447051737e1ac75beea41" -dependencies = [ - "bytes", - "futures-core", - "futures-util", - "http", - "mime", - "rand", - "thiserror", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[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.145" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" -dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", - "serde_core", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" -dependencies = [ - "libc", -] - -[[package]] -name = "slab" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - -[[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.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" - -[[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 = "thiserror" -version = "2.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "time" -version = "0.3.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" - -[[package]] -name = "time-macros" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tokio" -version = "1.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tower" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" -dependencies = [ - "log", - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - -[[package]] -name = "typetag" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf" -dependencies = [ - "erased-serde", - "inventory", - "once_cell", - "serde", - "typetag-impl", -] - -[[package]] -name = "typetag-impl" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "unicode-ident" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" - -[[package]] -name = "url" -version = "2.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" -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 = "uuid" -version = "1.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vespera" -version = "0.1.0" -dependencies = [ - "anyhow", - "proc-macro2", - "quote", - "serde", - "serde_json", - "syn", - "vespera_core", -] - -[[package]] -name = "vespera_core" -version = "0.1.0" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea879c944afe8a2b25fef16bb4ba234f47c694565e97383b36f3a878219065c" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf955aa904d6040f70dc8e9384444cb1030aed272ba3cb09bbc4ab9e7c1f34f5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] diff --git a/examples/third/Cargo.toml b/examples/third/Cargo.toml index 653a595b..bdfdce44 100644 --- a/examples/third/Cargo.toml +++ b/examples/third/Cargo.toml @@ -14,6 +14,6 @@ serde_json = "1" vespera = { path = "../../crates/vespera" } [dev-dependencies] -axum-test = "20.0" -insta = "1.47" +axum-test = "20.1" +insta = "1.48" diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock new file mode 100644 index 00000000..919d1a31 --- /dev/null +++ b/fuzz/Cargo.lock @@ -0,0 +1,603 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[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-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[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-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "itoa" +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", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[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 = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys", +] + +[[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 = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "vespera-fuzz" +version = "0.0.0" +dependencies = [ + "libfuzzer-sys", + "serde_json", + "tokio", + "vespera_inprocess", +] + +[[package]] +name = "vespera_inprocess" +version = "0.2.0" +dependencies = [ + "axum", + "bytes", + "http", + "http-body", + "http-body-util", + "serde", + "serde_json", + "tokio", + "tower", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 00000000..5da502d9 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,37 @@ +# Coverage-guided fuzzing for the wire trust boundary. +# +# Isolated from the root workspace via the empty `[workspace]` table +# below, so `cargo build --workspace` / `cargo test --workspace` at the +# repo root NEVER touch it — it builds only under `cargo fuzz` (nightly +# + libFuzzer; Linux/macOS). The deterministic, portable counterpart +# that DOES run under `cargo test` on every platform lives in +# `crates/vespera_inprocess/tests/wire_robustness.rs`. +# +# Run (Linux/macOS, requires `cargo install cargo-fuzz` + a nightly +# toolchain): +# cargo +nightly fuzz run wire_dispatch +[package] +name = "vespera-fuzz" +version = "0.0.0" +publish = false +edition = "2024" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +vespera_inprocess = { path = "../crates/vespera_inprocess" } +tokio = { version = "1", features = ["rt"] } +serde_json = "1" + +[[bin]] +name = "wire_dispatch" +path = "fuzz_targets/wire_dispatch.rs" +test = false +doc = false +bench = false + +# Empty table → this crate is its own workspace root, isolated from the +# repository's root workspace. +[workspace] diff --git a/fuzz/fuzz_targets/wire_dispatch.rs b/fuzz/fuzz_targets/wire_dispatch.rs new file mode 100644 index 00000000..b92c906e --- /dev/null +++ b/fuzz/fuzz_targets/wire_dispatch.rs @@ -0,0 +1,55 @@ +#![no_main] +//! Coverage-guided fuzz target for the binary wire trust boundary. +//! +//! libFuzzer feeds arbitrary bytes straight into +//! [`vespera_inprocess::dispatch_from_bytes`] and explores the parser; +//! the wire contract is asserted so any violation aborts and is +//! recorded as a reproducible crash: +//! +//! * it must **never panic** (no OOB / overflow / unwrap reachable from +//! hostile input), and +//! * it must **always return a well-formed length-prefixed wire +//! response** whose header is valid JSON carrying a numeric `status`. +//! +//! Run (Linux/macOS, nightly + `cargo install cargo-fuzz`): +//! ```text +//! cargo +nightly fuzz run wire_dispatch +//! ``` +//! +//! The portable, deterministic counterpart that runs under plain +//! `cargo test` on every platform is +//! `crates/vespera_inprocess/tests/wire_robustness.rs`. + +use std::sync::OnceLock; + +use libfuzzer_sys::fuzz_target; +use tokio::runtime::{Builder, Runtime}; +use vespera_inprocess::dispatch_from_bytes; + +fn runtime() -> &'static Runtime { + static RT: OnceLock = OnceLock::new(); + RT.get_or_init(|| { + Builder::new_current_thread() + .enable_all() + .build() + .expect("build current-thread runtime") + }) +} + +fuzz_target!(|data: &[u8]| { + let resp = dispatch_from_bytes(data.to_vec(), runtime()); + + // Contract — a violation here is a crash libFuzzer records for replay. + assert!(resp.len() >= 4, "response shorter than 4-byte length prefix"); + let header_len = u32::from_be_bytes(resp[..4].try_into().unwrap()) as usize; + assert!( + 4 + header_len <= resp.len(), + "header_len overflows response" + ); + let header: serde_json::Value = + serde_json::from_slice(&resp[4..4 + header_len]).expect("response header valid JSON"); + assert!( + header.get("status").and_then(serde_json::Value::as_u64).is_some(), + "response header carries a numeric status" + ); +}); diff --git a/libs/vespera-bridge-gradle-plugin/build.gradle.kts b/libs/vespera-bridge-gradle-plugin/build.gradle.kts index a5872120..5794248e 100644 --- a/libs/vespera-bridge-gradle-plugin/build.gradle.kts +++ b/libs/vespera-bridge-gradle-plugin/build.gradle.kts @@ -1,7 +1,12 @@ +import com.vanniktech.maven.publish.GradlePublishPlugin + plugins { `java-gradle-plugin` `kotlin-dsl` id("com.vanniktech.maven.publish") version "0.36.0" + // Gradle Plugin Portal publishing (`publishPlugins` task). Credentials + // come from GRADLE_PUBLISH_KEY / GRADLE_PUBLISH_SECRET env vars in CI. + id("com.gradle.plugin-publish") version "2.1.1" } group = "kr.devfive" @@ -23,6 +28,10 @@ repositories { } gradlePlugin { + // Required by the Plugin Portal (`com.gradle.plugin-publish`). + website.set("https://github.com/dev-five-git/vespera") + vcsUrl.set("https://github.com/dev-five-git/vespera.git") + plugins { create("vesperaBridge") { id = "kr.devfive.vespera-bridge" @@ -49,6 +58,11 @@ mavenPublishing { publishToMavenCentral(automaticRelease = true) if (shouldSign) signAllPublications() + // `com.gradle.plugin-publish` owns the sources/javadoc jars in this + // setup — vanniktech docs mandate GradlePublishPlugin (not GradlePlugin) + // when both plugins are applied, to avoid duplicate jar registration. + configure(GradlePublishPlugin()) + coordinates( groupId = "kr.devfive", artifactId = "vespera-bridge-gradle-plugin", diff --git a/libs/vespera-bridge/README.md b/libs/vespera-bridge/README.md index add6b8e4..9ab8def9 100644 --- a/libs/vespera-bridge/README.md +++ b/libs/vespera-bridge/README.md @@ -6,13 +6,13 @@ JNI bridge that lets a Java/Spring application embed a Rust [`vespera`](../../) kr.devfive vespera-bridge - 0.0.15 + 0.2.0 ``` ```kotlin dependencies { - implementation("kr.devfive:vespera-bridge:0.0.15") + implementation("kr.devfive:vespera-bridge:0.2.0") } ``` @@ -22,13 +22,13 @@ For Spring Boot apps the [`kr.devfive.vespera-bridge`](../vespera-bridge-gradle- ```kotlin plugins { - id("kr.devfive.vespera-bridge") version "0.0.15" + id("kr.devfive.vespera-bridge") version "0.1.1" } vespera { crateName.set("my_rust_lib") cargoRoot.set(rootProject.layout.projectDirectory.dir("../..")) - bridgeVersion.set("0.0.15") + bridgeVersion.set("0.2.0") } ``` @@ -56,17 +56,33 @@ Out of the box the autoconfigure module wires up: | Concern | Default | Override | |---|---|---| | **App selection** | Read `X-Vespera-App` request header; absent → default app | Property `vespera.bridge.app-header`, or custom [`AppNameResolver`](src/main/java/com/devfive/vespera/bridge/AppNameResolver.java) bean | -| **Dispatch mode** | [`BIDIRECTIONAL_STREAMING`](src/main/java/com/devfive/vespera/bridge/DispatchMode.java) for every request — safe for any payload size, transparent for the Rust router | Custom [`DispatchModeResolver`](src/main/java/com/devfive/vespera/bridge/DispatchModeResolver.java) bean | +| **Dispatch mode** | [`SmartDispatchModeResolver`](src/main/java/com/devfive/vespera/bridge/SmartDispatchModeResolver.java) since 0.2.0 — picks per request: [`DIRECT`](src/main/java/com/devfive/vespera/bridge/DispatchMode.java) (pooled direct buffers, no JNI array copies) for small/bodyless idempotent requests (GET/HEAD/PUT/DELETE/OPTIONS, Content-Length absent or ≤ 256 KiB) ~2.2 µs; `SYNC` (heap-buffered) for small non-idempotent (POST/PATCH ≤ 256 KiB) ~3.2 µs; `BIDIRECTIONAL_STREAMING` for the rest ~24.1 µs | Property `vespera.bridge.dispatch-mode: bidirectional-streaming` (opt out, restore pre-0.2.0 default), or custom [`DispatchModeResolver`](src/main/java/com/devfive/vespera/bridge/DispatchModeResolver.java) bean | | **URL pattern** | Single `@RequestMapping("/**")` catch-all — every vespera router URL exactly mirrors the published OpenAPI path | Set `vespera.bridge.controller-enabled: false` and supply your own controller | | **Body handling** | Servlet `InputStream` straight through to Rust (no buffering) for streaming modes; full read for sync/async | (encoded by the chosen `DispatchMode`) | -Why `BIDIRECTIONAL_STREAMING` as the default mode? It's the only mode that processes every payload size correctly without dispatch-time hints: +Why `smart` as the default mode (since 0.2.0)? Measured on a small `GET /health` round-trip through the real JNI boundary the cheapest safe path per request is 7–11× cheaper than unconditional streaming: -- **Tiny request / tiny response** (`/health` → `"ok"`): processed as a single chunk, negligible overhead. -- **Small JSON RPC** (`/users` → `{...}`): single chunk both ways. -- **Multi-GB upload + multi-GB download**: chunk-bounded both ways, ~32 KiB resident. +| Request shape | Mode | ns/round-trip | +|---|---|---| +| Small/bodyless + idempotent (GET/HEAD/PUT/DELETE/OPTIONS, Content-Length absent or ≤ 256 KiB) | `DIRECT` | ~2,200 | +| Small (≤ 256 KiB Content-Length) + non-idempotent (POST/PATCH) | `SYNC` | ~3,200 | +| Large or unknown-length body | `BIDIRECTIONAL_STREAMING` | ~24,100 | + +Trade-offs the new default makes on your behalf: + +- **DIRECT** writes the wire response straight into a pooled direct `ByteBuffer` (per-thread, 64 KiB → `vespera.direct.maxBufferBytes` default 4 MiB). On responses larger than the pooled buffer the Java side **retries once with a bigger buffer**, which re-runs the Rust handler. This is why DIRECT is gated on idempotent methods only. +- **SYNC** fully buffers the response on the JVM heap. The 256 KiB request-size gate keeps the response size reasonable for JSON-RPC-shaped traffic; large or unknown-length bodies still stream. +- **`BIDIRECTIONAL_STREAMING`** is unchanged for large/unknown-length bodies — multi-GB upload + multi-GB download still runs chunk-bounded, ~32 KiB resident each side. + +The Spring endpoints **always** mirror vespera's `openapi.json` — `smart` picks the JNI path per request without any URL prefix or path-based heuristic that could diverge from the Rust router's view of the world. -This means the Spring endpoints **always** mirror vespera's `openapi.json` — there is no URL prefix or mode-detection heuristic that could diverge from the Rust router's view of the world. +Restore the pre-0.2.0 default (every request that may carry a body streams both ways, ~24 µs per round-trip uniform) with: + +```yaml +vespera: + bridge: + dispatch-mode: bidirectional-streaming +``` ## Customization @@ -142,7 +158,7 @@ public class MyController { body); byte[] resp = VesperaBridge.dispatchBytes(wire); DecodedResponse d = VesperaBridge.decodeResponse(resp); - return ResponseEntity.status(d.status()).body(d.body()); + return ResponseEntity.status(d.status()).body(d.bodyBytes()); } } ``` @@ -200,11 +216,11 @@ bytes 4+N.. : raw body bytes (UTF-8 text or binary — - Multi-valued response headers (e.g. `set-cookie`) render as JSON arrays so semantics are preserved — they're never comma-joined. - All failure paths (malformed wire, Rust panic, no app registered) return a valid length-prefixed response with status `4xx` / `5xx`, so the decoder never has to special-case errors. -## Four dispatch modes +## Dispatch modes -`VesperaBridge` exposes four native methods that all share the same -wire format, same registered router, and same panic-safe -`catch_unwind` discipline: +`VesperaBridge` exposes six `byte[]`-based native methods plus a +direct-buffer path — all sharing the same wire format, same registered +router, and same panic-safe `catch_unwind` discipline: | Method | Mode | Java side return | Memory footprint | |---|---|---|---| @@ -212,12 +228,136 @@ wire format, same registered router, and same panic-safe | `dispatchAsync(CompletableFuture, byte[])` | async (`CompletableFuture`) | `void` (future completes) | full body in memory | | `dispatchStreaming(byte[], OutputStream)` | sync, response-streaming | `byte[]` (header only) | chunk-bounded response | | `dispatchFullStreaming(byte[], InputStream, OutputStream)` | sync, **bidirectional streaming** | `byte[]` (header only) | chunk-bounded both ways | +| `dispatchStreamingWithHeader(byte[], Consumer, OutputStream)` | sync, response-streaming | `void` (header via callback, fires before first body byte) | chunk-bounded response | +| `dispatchFullStreamingWithHeader(byte[], Consumer, InputStream, OutputStream)` | sync, bidirectional streaming | `void` (header via callback) | chunk-bounded both ways | +| `dispatchDirect(ByteBuffer, int, ByteBuffer)` | sync, **direct buffers** | `int` (response length / overflow code) | full body, but no Java heap arrays | Pick the mode that matches your workload: - Small JSON RPC, single request/response → `dispatchBytes` +- Hot small/bounded payloads where JNI copy overhead matters → `dispatchDirect` / `dispatchDirectPooled` - Async I/O coordination (parallel Java requests, non-blocking) → `dispatchAsync` + `CompletableFuture` - Large download / streaming response (video, PDF, server-sent events) → `dispatchStreaming` + `OutputStream` - **Large upload + large download** (file transfer proxy, video transcoding, 1 GB ↔ 1 GB) → `dispatchFullStreaming` + `InputStream` + `OutputStream` +- The `*WithHeader` variants let Spring-style controllers commit status/headers from the callback **before** the first body byte is written + +## Direct buffer dispatch (no JNI region copies) + +`dispatchDirect(ByteBuffer in, int inLen, ByteBuffer out)` reads the +wire request from a **direct** `ByteBuffer` and writes the wire +response into another, eliminating the two JNI +`GetByteArrayRegion`/`SetByteArrayRegion` copies and the per-call Java +heap array allocations that `dispatchBytes` pays. On the success path +the response is **streamed straight into the out buffer** (wire header +first, then each body frame at its final offset) — no intermediate +response `Vec`. To be precise about what remains: one plain native +memcpy on the request side (axum requires owned request bytes) plus +the per-frame body copies; `422` responses are materialised internally +to keep `validation_errors` hoisted in the wire header. Measured at +**1.4–3.4× per round-trip** versus `dispatchBytes` depending on +payload size. + +Contract: +- Both buffers MUST be direct (`ByteBuffer.allocateDirect`); heap + buffers are rejected with `IllegalArgumentException` before crossing + JNI. +- The request is read from absolute offsets `in[0..inLen]` — the + buffer's position/limit are **ignored**; `inLen` is authoritative. +- Return `>= 0`: a complete wire response occupies `out[0..n]`. +- Return `< 0`: `-(requiredSize)` — the response did not fit; buffer + contents are undefined (a prefix may have been written). + `requiredSize` is exact, but **retrying re-runs the Rust handler**, + so only retry idempotent requests. +- `Integer.MIN_VALUE`: response exceeds 2 GiB (unrepresentable). + +`dispatchDirectPooled(byte[] wireRequest, boolean retryOnOverflow)` +wraps the raw call with per-thread reusable direct buffers (64 KiB +initial, doubling up to the `vespera.direct.maxBufferBytes` system +property, default 4 MiB) and returns a read-only view of the response +valid until the next dispatch on the same thread. On response +overflow it throws `BufferTooSmallException(requiredSize)` unless +`retryOnOverflow` is `true` — pass `true` only for idempotent +requests, because the retry dispatches again. + +The fastest variant skips the intermediate wire `byte[]` entirely — +`dispatchDirectPooled(appName, method, path, query, headers, body, +retryOnOverflow)` encodes straight into the pooled direct buffer via +`encodeRequestInto(...)`, so the body is copied heap→direct exactly +once. `encodeRequestInto(..., ByteBuffer target)` is also public for +callers managing their own buffers; it returns the bytes written or +`-(required)` without touching the buffer when `target` is too small +(an encoding-side signal — no dispatch has run, growing and retrying +is always safe, unlike the response-overflow retry). + +For the Spring proxy, `SmartDispatchModeResolver` is the +**autoconfigured default since 0.2.0** — `DispatchMode.DIRECT` / +`SYNC` activate automatically on small bounded requests, no property +required. Restore the pre-0.2.0 default (every request that may carry +a body streams both ways) with: + +```yaml +vespera: + bridge: + dispatch-mode: bidirectional-streaming # default since 0.2.0: smart +``` + +`smart` picks the cheapest safe path per request (measured on a small +`GET /health` round-trip through the real JNI boundary): + +| Request shape | Mode | ns/round-trip | +|---|---|---| +| Small/bodyless + idempotent (GET/HEAD/PUT/DELETE/OPTIONS) | `DIRECT` | ~2,200 | +| Small (≤ 256 KiB Content-Length) + non-idempotent (POST/PATCH) | `SYNC` | ~3,200 | +| Large or unknown-length body | `BIDIRECTIONAL_STREAMING` | ~24,100 | + +The idempotency gate on DIRECT matters because a response that +overflows the pooled buffer (`vespera.direct.maxBufferBytes`, default +4 MiB) is retried — which re-runs the Rust handler once. SYNC never +re-runs the handler (safe for POST), but buffers the full response on +the heap, which the request-size gate keeps reasonable for +JSON-RPC-shaped traffic. + +Custom policies can still register the bean directly (the property is +ignored when a user `DispatchModeResolver` bean exists): + +```java +@Bean +public DispatchModeResolver dispatchModeResolver() { + return new BidirectionalStreamingDispatchModeResolver(); +} +``` + +### Virtual thread (Project Loom) limitation + +The pooled direct-buffer methods (`dispatchDirectPooled`) use +`ThreadLocal` to maintain per-thread reusable buffers +(64 KiB initial, growing to `vespera.direct.maxBufferBytes`, default +4 MiB). In Java 21+, `ThreadLocal` binds to the **virtual thread** +(not the carrier thread) — so on a virtual thread each dispatch would +allocate a fresh direct buffer, lose all pooling benefit, and +accumulate off-heap memory until the virtual thread is +garbage-collected. + +**Automatic mitigation (since 0.2.1):** `dispatchDirectPooled` detects +the calling thread via `Thread.isVirtual()` (resolved reflectively so +the library still targets Java 17) and, when it is a virtual thread, +**routes the request to the GC-managed heap `dispatchBytes` path +instead of the pooled direct buffer** — no per-vthread off-heap +accumulation, no configuration required. The DIRECT fast path keeps +its pooling benefit on platform threads (Tomcat's default request +pool); virtual-thread deployments transparently fall back to the heap +path at a small per-call allocation cost. + +You can still opt out of DIRECT entirely if you prefer streaming +end-to-end: +- Set `vespera.bridge.dispatch-mode=bidirectional-streaming` so DIRECT + is never chosen by the autoconfigured resolver. +- Or use `dispatchBytes`, `dispatchStreaming`, or + `dispatchFullStreaming` directly. +- Or lower `vespera.direct.maxBufferBytes` to reduce per-thread + allocation size on platform threads. + +`DispatchMode.BIDIRECTIONAL_STREAMING` is safe for virtual threads +and handles all payload sizes without pooling. ## Direct API (without the proxy controller) @@ -245,7 +385,7 @@ byte[] wireResponse = VesperaBridge.dispatchBytes(wireRequest); DecodedResponse resp = VesperaBridge.decodeResponse(wireResponse); System.out.println(resp.status()); // 200 System.out.println(resp.headers()); // { "content-type": "application/json", … } -System.out.println(new String(resp.body())); // the raw response body +System.out.println(new String(resp.bodyBytes())); // copies the raw response body ``` ### Async dispatch (`CompletableFuture`) @@ -321,11 +461,77 @@ try (InputStream upload = Files.newInputStream(Path.of("huge.mp4")); } ``` -Memory characteristics: **roughly 16 KiB chunk buffer + a 16-slot -mpsc channel buffer** in Rust, plus normal JVM `byte[]` chunks. A -1 GiB upload paired with a 1 GiB download runs in ~500 KiB resident -memory on each side. Backpressure is enforced naturally — if axum -reads slowly, `InputStream.read()` blocks on the bounded channel. +Memory characteristics: **roughly a 256 KiB chunk buffer + a 16-slot +mpsc channel buffer** in Rust (both configurable, see below), plus +normal JVM `byte[]` chunks. A 1 GiB upload paired with a 1 GiB +download runs in low-single-digit MiB resident memory on each side. +Backpressure is enforced naturally — if axum reads slowly, +`InputStream.read()` blocks on the bounded channel. + +#### Streaming tuning + +Both knobs are fixed for the process lifetime once the first dispatch +runs. Configuration precedence (first hit wins, then cached): + +1. **Programmatic setter** — `VesperaBridge.configureStreaming(chunkBytes, channelCapacity)` (Java API, call before or after init) +2. **System properties** — `vespera.streaming.chunkBytes`, `vespera.streaming.channelCapacity` +3. **Environment variables** — `VESPERA_STREAMING_CHUNK_BYTES`, `VESPERA_STREAMING_CHANNEL_CAPACITY` +4. **Built-in defaults** — 256 KiB chunk size, 16 channel slots + +| Setting | System property | Env var (fallback) | Default | Range | +|---|---|---|---|---| +| Chunk buffer size | `vespera.streaming.chunkBytes` | `VESPERA_STREAMING_CHUNK_BYTES` | 256 KiB | 4 KiB – 8 MiB | +| Request channel slots | `vespera.streaming.channelCapacity` | `VESPERA_STREAMING_CHANNEL_CAPACITY` | 16 | 1 – 1024 | +| Tokio worker threads | `vespera.runtime.workerThreads` | `VESPERA_RUNTIME_WORKERS` | logical CPUs | 1 – 1024 | + +**Java API** — call before `VesperaBridge.init(...)` for guaranteed precedence: + +```java +// Configure streaming parameters before init +VesperaBridge.configureStreaming( + 131072, // chunkBytes: 128 KiB (clamped to 4 KiB – 8 MiB) + 32 // channelCapacity: 32 slots (clamped to 1 – 1024) +); +VesperaBridge.init("my_rust_lib"); +``` + +When called before `init()`, values are stored as pending and applied +immediately after the native library loads, **before any dispatch can +occur**. This ensures the programmatic setter beats system properties +and environment variables (Rust-side precedence: setter > env > default). + +When called after `init()`, the native library is already loaded and +values are applied immediately (still beats env vars, but system +properties may have already been read during init). + +Throws `IllegalArgumentException` if `chunkBytes` is outside [4096, 8388608] or +`channelCapacity` is outside [1, 1024]. + +**System properties** — set before `VesperaBridge.init(...)`: + +```bash +java -Dvespera.streaming.chunkBytes=131072 \ + -Dvespera.streaming.channelCapacity=32 \ + -jar app.jar +``` + +**Environment variables** — fallback when no system property is set: + +```bash +export VESPERA_STREAMING_CHUNK_BYTES=131072 +export VESPERA_STREAMING_CHANNEL_CAPACITY=32 +java -jar app.jar +``` + +The worker-thread knob caps Rust's shared Tokio runtime — useful when +the JVM's own pools (Tomcat request threads, virtual-thread carriers) +compete with Tokio for the same cores, or when a container CPU limit +is lower than the host's logical CPU count. + +Larger chunks reduce the per-chunk JNI crossing cost (one +`SetByteArrayRegion` + one `OutputStream.write` per chunk) at the +price of per-stream memory — 256 KiB is a reasonable ceiling for +throughput-oriented deployments. ### Server-side response streaming (Spring `StreamingResponseBody`) @@ -365,23 +571,19 @@ byte[] wire = VesperaBridge.encodeRequest( pdf); DecodedResponse resp = VesperaBridge.decodeResponse( VesperaBridge.dispatchBytes(wire)); -assert Arrays.equals(pdf, resp.body()); // exact round-trip +assert Arrays.equals(pdf, resp.bodyBytes()); // exact round-trip (copy on demand) ``` -A Rust handler returning a binary response (e.g. `image/png`) flows the same way: `VesperaProxyController` inspects the response `Content-Type` and returns `ResponseEntity` for binary content, `ResponseEntity` for text-like content. +A Rust handler returning a binary response (e.g. `image/png`) flows the same way: `VesperaProxyController` returns `ResponseEntity` for **every** content type — the wire header already carries the exact `Content-Type`, which Spring's `ByteArrayHttpMessageConverter` writes verbatim. (Before 0.2.1 text-like content types were delivered as `ResponseEntity`; that path was dropped because it forced a redundant UTF-8 decode→re-encode round-trip.) ## VesperaProxyController behaviour `@RequestMapping("/**")` catches every HTTP request, regardless of method or content type, and: 1. Collects all incoming headers (lowercased keys). -2. Reads the body as `byte[]` (Spring's `@RequestBody byte[]`, `consumes = MediaType.ALL_VALUE`). -3. Encodes via `VesperaBridge.encodeRequest(...)` → `dispatchBytes(byte[])`. -4. Decodes via `VesperaBridge.decodeResponse(byte[])`. -5. Returns `ResponseEntity` for text-like `Content-Type` (e.g. `text/*`, `application/json`, `+json`, `+xml`, `application/xml`, `application/javascript`, `application/yaml`, `application/x-www-form-urlencoded`, `application/graphql`). -6. Returns `ResponseEntity` for everything else. - -Missing `Content-Type` defaults to "text" — matching the long-standing Vespera convention of treating unspecified content as JSON-shaped. +2. Asks the configured `DispatchModeResolver` which mode serves this request (default since 0.2.0: `SmartDispatchModeResolver` — DIRECT for small/bodyless idempotent requests, SYNC for small non-idempotent requests, BIDIRECTIONAL_STREAMING for everything else; opt out with `vespera.bridge.dispatch-mode=bidirectional-streaming`). +3. For `SYNC` / `ASYNC` / `STREAMING` / `DIRECT` modes the body is read into `byte[]` first (bodyless requests — explicit `Content-Length: 0`, e.g. the small idempotent GETs the SmartDispatch resolver routes through DIRECT — skip the read and reuse a shared empty array), then encoded via `VesperaBridge.encodeRequest(...)` and dispatched through the matching native method. +4. Sync/async responses are parsed straight from the wire response via the allocation-lean `WireHeaderReader` (status + headers) and returned as `ResponseEntity` for **every** `Content-Type` — the body is sliced once from the wire tail; the `Content-Type` header is carried verbatim, so no text/binary branching is needed. Streaming and DIRECT modes write status/headers and body straight to the servlet response. ## Native library loading @@ -396,6 +598,36 @@ The supported triples are `linux-x86_64`, `linux-aarch64`, `macos-x86_64`, `maco See [`examples/rust-jni-demo`](../../examples/rust-jni-demo/) for a complete Rust + Spring Boot integration including build scripts, native bundling, and a curl smoke test. +## 0.2.0 breaking changes + +### 1. Autoconfigured default `DispatchModeResolver` flipped to `SmartDispatchModeResolver` + +Pre-0.2.0 the autoconfigured default was [`BidirectionalStreamingDispatchModeResolver`](src/main/java/com/devfive/vespera/bridge/BidirectionalStreamingDispatchModeResolver.java) — every request that may carry a body streamed both ways, ~24.1 µs per round-trip uniform. Since 0.2.0 the default is [`SmartDispatchModeResolver`](src/main/java/com/devfive/vespera/bridge/SmartDispatchModeResolver.java) — small bounded idempotent requests take `DIRECT` (~2.2 µs), small non-idempotent take `SYNC` (~3.2 µs), everything else still streams (~24.1 µs). + +| Request shape | Pre-0.2.0 mode | 0.2.0+ mode | +|---|---|---| +| Small/bodyless idempotent (GET/HEAD/PUT/DELETE/OPTIONS, ≤ 256 KiB CL or no CL) | `STREAMING` / `BIDIRECTIONAL_STREAMING` | `DIRECT` | +| Small non-idempotent (POST/PATCH, ≤ 256 KiB CL) | `BIDIRECTIONAL_STREAMING` | `SYNC` | +| Large or unknown-length body | `BIDIRECTIONAL_STREAMING` | `BIDIRECTIONAL_STREAMING` | + +Trade-offs the new default makes: +- **DIRECT** writes the wire response straight into a pooled per-thread direct `ByteBuffer` (64 KiB → `vespera.direct.maxBufferBytes`, default 4 MiB). Responses larger than the pooled buffer trigger a single retry with a bigger buffer, which **re-runs the Rust handler** — which is why DIRECT is gated on idempotent methods only. +- **SYNC** fully buffers the response on the JVM heap. The 256 KiB request-size gate keeps the response size reasonable for JSON-RPC-shaped traffic; large or unknown-length bodies still stream. + +**Opt out** (restore the pre-0.2.0 default): + +```yaml +vespera: + bridge: + dispatch-mode: bidirectional-streaming +``` + +Or register a custom [`DispatchModeResolver`](src/main/java/com/devfive/vespera/bridge/DispatchModeResolver.java) bean — `@ConditionalOnMissingBean` ensures it wins over both the property and the autoconfigured default. + +### 2. `DecodedResponse.body()` returns `ByteBuffer` + +`DecodedResponse.body()` now returns a read-only `java.nio.ByteBuffer` (zero-copy view over the wire bytes); the owned `byte[]` materialisation moved to `DecodedResponse.bodyBytes()`. Callers that previously consumed `body()` as `byte[]` must switch to `bodyBytes()` (or read directly from the buffer). + ## Migrating from the JSON-envelope bridge (≤ 0.0.13) The pre-0.0.14 bridge used `dispatch(String) → String` with base64-encoded binary bodies. Migration: diff --git a/libs/vespera-bridge/build.gradle.kts b/libs/vespera-bridge/build.gradle.kts index cce0451a..7f9ea1ef 100644 --- a/libs/vespera-bridge/build.gradle.kts +++ b/libs/vespera-bridge/build.gradle.kts @@ -31,6 +31,13 @@ dependencies { api("com.fasterxml.jackson.core:jackson-databind:2.17.0") testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") + // MockHttpServletRequest for resolver unit tests (no servlet container). + testImplementation("org.springframework:spring-test:6.1.6") + // WebApplicationContextRunner for autoconfigure branch tests + // (its AssertableWebApplicationContext implements AssertJ's + // AssertProvider, so assertj-core must be on the test classpath). + testImplementation("org.springframework.boot:spring-boot-test:3.2.5") + testImplementation("org.assertj:assertj-core:3.25.3") testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.10.2") } diff --git a/libs/vespera-bridge/docs/jni-before-after-2026-06-11.md b/libs/vespera-bridge/docs/jni-before-after-2026-06-11.md new file mode 100644 index 00000000..496285ed --- /dev/null +++ b/libs/vespera-bridge/docs/jni-before-after-2026-06-11.md @@ -0,0 +1,237 @@ +# JNI BEFORE ↔ AFTER benchmark report (2026-06-11) + +## Headline + +The v0.2.0 JNI break is justified by the hot-path wins it unlocks: the new `direct_pooled` ByteBuffer path completes the tiny `/health` round-trip in **2,349 ns/op**, **1.55× faster than the 0.1.1-era sync baseline** (3,643 ns/op), and the existing sync byte-array path is still **20% faster** after the series. The largest measured gains are in binary streaming throughput: AFTER is **2.14× to 3.26× faster** across 16 KiB → 256 KiB chunks, peaking at **14,458 MiB/s** for 256 KiB chunks versus **4,440 MiB/s** BEFORE. Response decoding now exposes the zero-copy API that did not exist BEFORE; that API gap is the core reason the breaking change is worth taking. + +Small-request streaming and async latency did **not** improve in this run: response-only streaming, bidirectional streaming, and async-completable-future medians regressed versus the backported 0.1.1 harness. The async row is called out below as gate input for the follow-up attach/JMethodID optimization decision. + +## Latency table + +Protocol: 3 JVM invocations per side; run 1 discarded as cold; table value is the median of runs 2–3 (for two retained values, arithmetic midpoint). Lower is better. + +| mode | BEFORE ns/op | AFTER ns/op | delta | speedup | +|---|---:|---:|---:|---:| +| `sync_dispatch_bytes` | 3,643 | 2,930 | -713 ns (-19.6%) | 1.24× faster | +| `direct_pooled` | N/A[^direct-na] | 2,349 | N/A | N/A | +| `response_streaming_only` | 3,735 | 6,922 | +3,187 ns (+85.3%) | 0.54× | +| `bidirectional_streaming` | 11,752 | 20,988 | +9,236 ns (+78.6%) | 0.56× | +| `async_completable_future` | 22,071 | 23,869 | +1,798 ns (+8.1%) | 0.92× | + +[^direct-na]: `dispatchDirectPooled` / direct `ByteBuffer` dispatch did not exist in the 0.1.1 bridge, so the BEFORE harness drops this mode. Compared to the old BEFORE `sync_dispatch_bytes` baseline, AFTER `direct_pooled` is **1.55× faster**. + +## Throughput table + +Protocol: 64 MiB payload, 3 warmup iterations + 10 measured iterations per JVM; 3 JVM invocations per chunk size per side; run 1 discarded as cold; table value is the median of runs 2–3. Higher is better. + +| chunkBytes | BEFORE MiB/s | AFTER MiB/s | delta | +|---:|---:|---:|---:| +| 16,384 | 4,859.8 | 10,407.9 | +5,548.2 MiB/s (+114.2%, 2.14×) | +| 65,536 | 4,711.3 | 11,587.0 | +6,875.7 MiB/s (+146.0%, 2.46×) | +| 262,144 | 4,439.9 | 14,458.3 | +10,018.5 MiB/s (+225.6%, 3.26×) | + +## Raw measured values + +Logs are retained in `%TEMP%` as `bench-before-*.log` and `bench-after-*.log`. + +### Small request latency (`ns/op`) + +| side | run | `sync_dispatch_bytes` | `direct_pooled` | `response_streaming_only` | `bidirectional_streaming` | `async_completable_future` | +|---|---:|---:|---:|---:|---:|---:| +| BEFORE | 1 (discarded) | 3,201 | N/A | 3,531 | 12,101 | 21,381 | +| BEFORE | 2 | 3,867 | N/A | 3,932 | 13,188 | 21,664 | +| BEFORE | 3 | 3,419 | N/A | 3,538 | 10,315 | 22,478 | +| AFTER | 1 (discarded) | 3,026 | 2,223 | 6,485 | 20,150 | 25,163 | +| AFTER | 2 | 2,872 | 2,221 | 6,475 | 18,947 | 25,444 | +| AFTER | 3 | 2,987 | 2,476 | 7,368 | 23,029 | 22,294 | + +### Streaming throughput (`MiB/s`, mean ± stddev printed by the test) + +| side | chunkBytes | run | throughput | stddev | +|---|---:|---:|---:|---:| +| BEFORE | 16,384 | 1 (discarded) | 5,039.0 | 754.0 | +| BEFORE | 16,384 | 2 | 4,732.4 | 565.3 | +| BEFORE | 16,384 | 3 | 4,987.1 | 702.3 | +| BEFORE | 65,536 | 1 (discarded) | 5,007.3 | 660.6 | +| BEFORE | 65,536 | 2 | 4,627.3 | 577.8 | +| BEFORE | 65,536 | 3 | 4,795.3 | 738.8 | +| BEFORE | 262,144 | 1 (discarded) | 4,966.2 | 686.1 | +| BEFORE | 262,144 | 2 | 4,485.1 | 618.3 | +| BEFORE | 262,144 | 3 | 4,394.6 | 540.1 | +| AFTER | 16,384 | 1 (discarded) | 10,446.8 | 772.1 | +| AFTER | 16,384 | 2 | 10,377.0 | 1,270.2 | +| AFTER | 16,384 | 3 | 10,438.8 | 991.3 | +| AFTER | 65,536 | 1 (discarded) | 13,017.3 | 1,898.4 | +| AFTER | 65,536 | 2 | 12,882.9 | 1,952.3 | +| AFTER | 65,536 | 3 | 10,291.1 | 1,868.3 | +| AFTER | 262,144 | 1 (discarded) | 13,140.2 | 2,093.0 | +| AFTER | 262,144 | 2 | 13,907.1 | 1,462.6 | +| AFTER | 262,144 | 3 | 15,009.5 | 1,011.7 | + +## Gate input: `async_completable_future` + +`async_completable_future` was explicitly measured on both sides with the same backported harness. BEFORE retained runs were **21,664** and **22,478 ns/op** (median **22,071 ns/op**). AFTER retained runs were **25,444** and **22,294 ns/op** (median **23,869 ns/op**). That is an **8.1% latency regression** in this protocol, so attach/JMethodID async follow-up should be decided from this row rather than inferred from Rust-side criterion or from sync/direct results. + +## Methodology + +- BEFORE base commit: `6242533483056b20bb363c34917133a395044aa8` (`6242533`). +- BEFORE throwaway worktree head for the measurement: `01592f4cca9649fdfe9a0d68503a38284a37ad66` on branch `before-bench-harness`. +- AFTER commit: `015a444b2f1dd50c8ab0c4a7c2729aac2b1aa58e` from the main working tree. +- Java: `openjdk version "21.0.8" 2025-07-15 LTS`, `OpenJDK Runtime Environment Zulu21.44+17-CA (build 21.0.8+9-LTS)`, `OpenJDK 64-Bit Server VM Zulu21.44+17-CA (build 21.0.8+9-LTS, mixed mode, sharing)`. +- Cargo: `cargo 1.96.0 (30a34c682 2026-05-25)`. +- OS/CPU: Microsoft Windows 11 Pro 10.0.26200; AMD Ryzen 9 9950X 16-Core Processor; 16 cores / 32 logical processors. +- Small-request benchmark: `SmallRequestLatencyBenchTest`, 20,000 warmup iterations + 100,000 measured iterations, `-Dvespera.bench=true`. +- Streaming benchmark: `StreamingThroughputBenchTest`, 64 MiB payload, 3 warmup iterations + 10 measured iterations, `-Dvespera.bench=true`, chunk sizes `16384`, `65536`, `262144` via `-Dvespera.streaming.chunkBytes=`. +- JVM protocol: 3 Gradle/JVM invocations per side per benchmark; discard run 1 as cold; report median of runs 2–3 and retain both raw values above. +- Gradle invocation rule: every Gradle call used `--console=plain --no-daemon`; benchmark runs also used `--rerun-tasks` after Gradle's up-to-date check suppressed repeated benchmark execution. +- BEFORE `CARGO_TARGET_DIR` isolation: all BEFORE Cargo commands used `C:\Users\owjs3\Desktop\projects\vespera-before-bench\target-isolated`, so the main repo `target/` was never shared with the worktree. +- BEFORE cdylib evidence: isolated build produced `C:\Users\owjs3\Desktop\projects\vespera-before-bench\target-isolated\release\rust_jni_demo.dll`, length `1,774,592`, timestamp `2026-06-11 17:21:52 UTC`; because the Gradle plugin reads `target/release`, the DLL was copied to the worktree-local `target\release\rust_jni_demo.dll`, then bundled as `examples\rust-jni-demo\java\demo-app\build\resources\main\native\windows-x86_64\rust_jni_demo.dll`, length `1,774,592`, timestamp `2026-06-11 17:27:02 UTC`. +- AFTER cdylib evidence: main build produced `C:\Users\owjs3\Desktop\projects\vespera\target\release\rust_jni_demo.dll`, length `1,521,664`, timestamp `2026-06-11 14:35:03 UTC`; Gradle bundled `examples\rust-jni-demo\java\demo-app\build\resources\main\native\windows-x86_64\rust_jni_demo.dll`, length `1,521,664`, timestamp `2026-06-11 17:30:38 UTC`. +- Bridge versions: Maven local had both `kr/devfive/vespera-bridge/0.1.1` and `kr/devfive/vespera-bridge/0.2.0`. BEFORE `demo-app` was patched to `bridgeVersion.set("0.1.1")`; AFTER already pins `0.2.0`. +- BEFORE route support: the benchmark files did not exist at `6242533`, and the streaming benchmark's target route `POST /echo/stream` also did not exist. The throwaway worktree backported the current streaming echo route only to keep the throughput benchmark measuring JNI transport rather than route availability. Main production code was not changed. +- API availability: AFTER's `direct_pooled` / direct `ByteBuffer` path measures an API that did not exist BEFORE. The BEFORE gap is therefore recorded as `N/A`, and that missing path is part of the measured improvement unlocked by the v0.2.0 break. + +### Verbatim backport diff between AFTER bench files and BEFORE-patched bench files + +```diff +diff --git a/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/SmallRequestLatencyBenchTest.java "b/..\\vespera-before-bench\\examples\\rust-jni-demo\\java\\demo-app\\src\\test\\java\\kr\\go\\demo\\SmallRequestLatencyBenchTest.java" +index 3327283..785f254 100644 +--- a/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/SmallRequestLatencyBenchTest.java ++++ "b/..\\vespera-before-bench\\examples\\rust-jni-demo\\java\\demo-app\\src\\test\\java\\kr\\go\\demo\\SmallRequestLatencyBenchTest.java" +@@ -6,7 +6,6 @@ import com.devfive.vespera.bridge.VesperaBridge; + import java.io.ByteArrayInputStream; + import java.io.IOException; + import java.io.OutputStream; +-import java.nio.ByteBuffer; + import java.util.Map; + import java.util.concurrent.CompletableFuture; + import java.util.concurrent.TimeUnit; +@@ -18,16 +17,8 @@ import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + * E2E small-request latency benchmark through the REAL JNI boundary — + * quantifies what {@code vespera.bridge.dispatch-mode=smart} buys for + * the requests it targets (small bounded idempotent), by comparing the +- * three dispatch modes on the same tiny {@code GET /health} round-trip: +- * +- *

    +- *
  • {@code SYNC} — {@code encodeRequest} → {@code dispatchBytes} +- * → {@code decodeResponse} (two JNI array copies)
  • +- *
  • {@code DIRECT} — {@code dispatchDirectPooled} fast path +- * (pooled direct buffers, no Java heap arrays)
  • +- *
  • {@code BIDIRECTIONAL_STREAMING} — the autoconfigured default +- * ({@code dispatchFullStreamingWithHeader})
  • +- *
++ * dispatch modes available in the 0.1.1 bridge on the same tiny ++ * {@code GET /health} round-trip. + * + *

Gated behind {@code -Dvespera.bench=true} so normal test runs and + * CI skip it: +@@ -69,15 +60,6 @@ class SmallRequestLatencyBenchTest { + return VesperaBridge.decodeResponse(VesperaBridge.dispatchBytes(wire)).status(); + } + +- private static int directOnce() { +- ByteBuffer resp = +- VesperaBridge.dispatchDirectPooled(null, "GET", "/health", null, HEADERS, null, true); +- // Consume like the controller does: header region must be parsed. +- byte[] out = new byte[resp.remaining()]; +- resp.get(out); +- return VesperaBridge.decodeResponse(out).status(); +- } +- + private static int streamingOnce() throws IOException { + byte[] wireHeader = VesperaBridge.encodeRequestHeader("GET", "/health", null, HEADERS); + CountingOutputStream sink = new CountingOutputStream(); +@@ -137,7 +119,6 @@ class SmallRequestLatencyBenchTest { + @Test + void smallRequestLatencyByMode() throws IOException { + long sync = measure("sync_dispatch_bytes", SmallRequestLatencyBenchTest::syncOnce); +- long direct = measure("direct_pooled", SmallRequestLatencyBenchTest::directOnce); + long respStreaming = + measure( + "response_streaming_only", +@@ -149,12 +130,8 @@ class SmallRequestLatencyBenchTest { + "async_completable_future", + SmallRequestLatencyBenchTest::asyncOnce); + System.out.printf( +- "VESPERA_BENCH summary direct_vs_streaming=%.2fx direct_vs_sync=%.2fx" +- + " resp_only_vs_bidi=%.2fx async_vs_sync=%.2fx async_vs_direct=%.2fx%n", +- (double) streaming / direct, +- (double) sync / direct, ++ "VESPERA_BENCH summary resp_only_vs_bidi=%.2fx async_vs_sync=%.2fx%n", + (double) streaming / respStreaming, +- (double) async / sync, +- (double) async / direct); ++ (double) async / sync); + } + } + +--- StreamingThroughputBenchTest.java diff --- +``` + +`StreamingThroughputBenchTest.java` had no source-level diff after copying it into the BEFORE worktree; its bridge methods existed in 0.1.1. The separate route backport described above was required because `POST /echo/stream` was not present at `6242533`. + +## Deferred + +Text-envelope path optimization is intentionally deferred. The binary wire fast path covers the dominant JNI use case: Spring/Java proxying real request and response bytes through the length-prefixed binary envelope without base64 or domain JSON parsing. The text-envelope path is a niche direct-API fallback rather than the JNI hot path, so this perf series focuses on byte-array region copies, cached JNI method lookups, direct buffers, and binary streaming first. + +## Traps encountered and resolution + +- `dispatchDirectPooled` was absent from 0.1.1: dropped `direct_pooled` on the BEFORE side and reported it as `N/A` with the API-gap footnote. +- `POST /echo/stream` was absent from `6242533`: backported the current streaming echo route only in the throwaway worktree so streaming throughput compares JNI transport rather than a 404/route mismatch. +- Gradle repeated test invocations were `UP-TO-DATE`: reran the benchmark protocol with `--rerun-tasks` while retaining `--console=plain --no-daemon`. +- The Gradle plugin bundles from `target/release`: BEFORE Cargo still built with isolated `CARGO_TARGET_DIR=...\target-isolated`, then the built DLL was copied into the worktree-local `target/release` path before Gradle bundling. +- GPG signing blocked the throwaway worktree commit: the first commit attempt timed out in GPG; the ephemeral worktree commits were created with per-command `git -c commit.gpgsign=false`, with no config change and no push. + +## Re-gate: async attach optimization + +Decision: **keep the async completion daemon-attach optimization**. `jni` 0.22.4 source shows `JavaVM::attach_current_thread` is already a permanent cached attachment (`java_vm.rs` lines 450-469), while `attach_current_thread_for_scope` is the scoped detach-on-return API (`java_vm.rs` lines 500-513). The crate does not expose a safe daemon attachment helper and explicitly says daemon threads are not directly supported (`java_vm.rs` lines 1027-1047), so the async completion path uses JNI 1.4's raw `AttachCurrentThreadAsDaemon` entry from `jni-sys` and caches its `JNIEnv` per Tokio worker thread, with a per-completion local frame to prevent local-reference accumulation. + +Protocol: same 3 JVM invocations; run 1 discarded as cold; retained value is the arithmetic midpoint of runs 2-3. Gate metric is `async_completable_future`. + +| side | run | `sync_dispatch_bytes` | `direct_pooled` | `response_streaming_only` | `bidirectional_streaming` | `async_completable_future` | +|---|---:|---:|---:|---:|---:|---:| +| CURRENT | 1 (discarded) | 3,579 | 2,755 | 7,518 | 21,992 | 28,651 | +| CURRENT | 2 | 3,409 | 3,299 | 6,420 | 22,845 | 24,045 | +| CURRENT | 3 | 3,188 | 2,462 | 6,563 | 17,237 | 21,466 | +| DAEMON | 1 (discarded) | 2,890 | 2,265 | 6,119 | 16,315 | 20,270 | +| DAEMON | 2 | 2,987 | 2,188 | 6,307 | 18,893 | 21,027 | +| DAEMON | 3 | 3,158 | 2,263 | 6,242 | 18,002 | 21,921 | + +| metric | CURRENT median ns/op | DAEMON median ns/op | improvement | +|---|---:|---:|---:| +| `async_completable_future` | 22,756 | 21,474 | **1,282 ns/op faster** (-5.6%) | + +The measured win is above the **100 ns/op** keep gate. Follow-up review found that the daemon-attached Tokio worker must explicitly clear pending Java exceptions after every completion callback because it no longer gets jni-rs scoped-detach cleanup. The implementation now clears pending exceptions after callback success, callback error, and callback unwind while preserving the callback return/error. A targeted regression guard, `AsyncDispatchExceptionHygieneTest.throwingFutureCompleteDoesNotPoisonNextAsyncCompletion`, first forces `CompletableFuture.complete()` to throw and then asserts a normal `dispatchAsync` still completes with status 200; it failed before the cleanup with a timeout and passes after the fix. A single post-fix sanity bench run measured `async_completable_future` at **16,107 ns/op** (informational only; not a replacement for the 3-JVM gate). Verification also passed `cargo clippy --workspace --all-targets -- -D warnings`, `cargo fmt --check`, `cargo test --workspace`, `cargo build -p rust-jni-demo --release`, and the full `:demo-app:test` Gradle suite (including `StreamingClosureStressTest` and the new hygiene guard). + +## Addendum (same day, later session): allocator + streaming buffer pooling + +Two further changes, paired same-session benches (GET /health, 100k iters, mimalloc build): + +| mode | default alloc | + mimalloc | + chunk-buffer pooling | total delta | +|---|---:|---:|---:|---| +| sync_dispatch_bytes | 2,870 | 2,314 | 2,322 | **-19%** | +| direct_pooled | 2,376 | 2,017 | 2,000 | **-16%** | +| response_streaming | 18,617* | 17,610 | **2,434** | **-87%** | +| bidirectional_streaming | 37,543* | 32,326 | **2,605** | **-93%** | +| async_completable_future | 22,038 | 19,468 | ~15,000 | **-32%** | + +\* with the 256 KiB chunk default: each streaming dispatch allocated+zeroed fresh 256 KiB Java arrays (bidi: two), costing ~10µs each — this addendum's TLS pooling (per-OS-thread cached Global, fresh-alloc fallback when leased/reentrant) removes that per-dispatch cost entirely while keeping the 256 KiB throughput benefit for large transfers. mimalloc is opt-in via the vespera `mimalloc` cargo feature. + +## Concurrency frontier (B + C rounds, 32-logical-core machine) + +Single-thread latency was at its floor; the remaining headroom was CONCURRENT throughput. Measured with ConcurrencyBenchTest (N platform threads, 3s measure). + +### Diagnostic chain +1. **Artifact-drift caught by JFR**: the local mavenLocal bridge jar was stale (pre-P1 \ObjectMapper.readTree\) — every prior local demo bench measured the OLD decode. \gradlew clean jar publishToMavenLocal\ (the \clean\ is mandatory; same-version republish is UP-TO-DATE-skipped) fixed it. Source/release were always correct (CI republishes fresh). +2. **P1 confirmed once deployed**: JsonParser streaming decode cut per-op allocation **-31%** (3.5KB→2.4KB); this alone raised direct 16-thread throughput **+56%** — proving the plateau was substantially GC/allocation-driven below the knee. +3. **B (further decode-alloc reduction)**: manual BE header-len read + lazy header map + fewer body-view ByteBuffers → **-4~7%** alloc, but 16-thread throughput **+0.7%** (noise). Conclusion: past the GC knee, decode allocation is NOT the concurrency lever. +4. **C diagnostic**: worker-thread sweep — 16T throughput is INSENSITIVE to \ espera.runtime.workerThreads\ (2/8/32/64 all ~3.6-3.9M ops/s) → NOT worker saturation. The bottleneck is shared-runtime \lock_on\ context-enter contention (every sync dispatch block_on's one shared multi-thread Tokio runtime). +5. **C fix**: per-OS-thread \ hread_local!\ current-thread Tokio runtime for the sync paths (dispatchBytes, dispatchDirect) — zero shared-runtime state. Streaming/async keep the shared multi-thread RUNTIME. + +### C result (16-thread, the saturation metric) +| mode | before ops/s (eff) | after ops/s (eff) | delta | +|---|---|---|---| +| sync_dispatch_bytes | 4.09M (49.5%) | 4.67M (60.2%) | **+14.2%** | +| direct_pooled | 3.45M (47.5%) | 4.64M (66.8%) | **+34.6%** | + +Single-thread latency unchanged. Oracle-reviewed: TLS runtime drops at thread exit (outside block_on), reentrant nested dispatch panics are caught by catch_unwind → 500 wire, detached \ okio::spawn\ on the sync path no longer outlives block_on (documented, fragile pattern). Streaming bidirectional (spawn_blocking) + async (RUNTIME.spawn) verified unaffected. diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/BidirectionalStreamingDispatchModeResolver.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/BidirectionalStreamingDispatchModeResolver.java index e7a80011..8b873e68 100644 --- a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/BidirectionalStreamingDispatchModeResolver.java +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/BidirectionalStreamingDispatchModeResolver.java @@ -3,26 +3,44 @@ import jakarta.servlet.http.HttpServletRequest; /** - * Default {@link DispatchModeResolver} — always returns - * {@link DispatchMode#BIDIRECTIONAL_STREAMING}. + * Conservative {@link DispatchModeResolver} — bidirectional streaming + * for every request that may carry a body, with one semantics-preserving + * fast path: provably bodyless requests (see + * {@link DispatchModeResolver#definitelyBodyless}) use response-only + * {@link DispatchMode#STREAMING}, skipping the request-pull plumbing + * that costs ~16 µs per request even when there is nothing to + * pull (measured 24.1 µs → 7.7 µs on a small GET). * - *

This is the safest universal default: every payload size - * (including 0-byte requests and tiny JSON bodies) is processed - * correctly through the bidirectional streaming JNI path, and the - * Spring endpoints exactly mirror the URLs in vespera's generated + *

Pre-0.2.0 default; opt-out since 0.2.0. The + * autoconfigured default flipped to {@link SmartDispatchModeResolver} + * in vespera-bridge 0.2.0 (DIRECT 2.2 µs / SYNC 3.2 µs vs + * bidirectional 24.1 µs on small bounded requests). Restore this + * resolver as the default with + * {@code vespera.bridge.dispatch-mode=bidirectional-streaming}, or + * register it explicitly as a {@code @Bean DispatchModeResolver}. + * + *

This remains the safest universal policy: every payload size is + * processed correctly (responses always stream chunk-bounded; + * request bodies stream whenever one can exist), and the Spring + * endpoints exactly mirror the URLs in vespera's generated * {@code openapi.json}. No path-based mode discrimination means no - * surprise divergence from the Rust router's view. + * surprise divergence from the Rust router's view, and (unlike DIRECT + * in the smart default) the Rust handler is never re-run on response + * overflow. * *

Replace this with a custom {@link DispatchModeResolver} bean if * your application needs different modes for different routes * (e.g. sync for sub-KB JSON RPC, async for parallel I/O - * coordination). + * coordination) — or to restore unconditional bidirectional + * streaming with a one-line lambda. */ public final class BidirectionalStreamingDispatchModeResolver implements DispatchModeResolver { @Override public DispatchMode resolveMode(HttpServletRequest request) { - return DispatchMode.BIDIRECTIONAL_STREAMING; + return DispatchModeResolver.definitelyBodyless(request) + ? DispatchMode.STREAMING + : DispatchMode.BIDIRECTIONAL_STREAMING; } } diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DispatchMode.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DispatchMode.java index 192520f3..af9b3071 100644 --- a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DispatchMode.java +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DispatchMode.java @@ -4,14 +4,22 @@ * How {@link VesperaProxyController} dispatches an incoming HTTP * request through the Rust JNI bridge. * - *

The default {@link DispatchModeResolver} returns - * {@link #BIDIRECTIONAL_STREAMING} for every request so that the - * Spring side stays transparent to the vespera Rust router — the - * routes published in the generated {@code openapi.json} are reached - * via the same URLs, regardless of whether the underlying handler - * emits a small JSON body or streams a multi-gigabyte file. Users - * who want a different policy (sync for small JSON RPC, async for - * heavy I/O coordination, …) can register a custom + *

The autoconfigured default {@link DispatchModeResolver} since + * vespera-bridge 0.2.0 is {@link SmartDispatchModeResolver}: small + * bounded idempotent requests take {@link #DIRECT} (~2.2 µs), small + * non-idempotent requests take {@link #SYNC} (~3.2 µs), everything + * else falls back to {@link #BIDIRECTIONAL_STREAMING} (~24 µs). The + * Spring side stays transparent to the vespera Rust router either + * way — the routes published in the generated {@code openapi.json} + * are reached via the same URLs, regardless of whether the underlying + * handler emits a small JSON body or streams a multi-gigabyte file. + * + *

Restore the pre-0.2.0 default (every request that may carry a + * body streams both ways) with the conservative opt-out: + * {@code vespera.bridge.dispatch-mode=bidirectional-streaming} → + * {@link BidirectionalStreamingDispatchModeResolver}. Users who + * want a different policy (sync for small JSON RPC, async for heavy + * I/O coordination, …) can register a custom * {@link DispatchModeResolver} bean — {@code @ConditionalOnMissingBean} * ensures the default is automatically disabled. */ @@ -50,11 +58,33 @@ public enum DispatchMode { * java.util.function.Consumer, java.io.InputStream, * java.io.OutputStream)}. * Both request and response bodies stream chunk-by-chunk. - * This is the default mode — it works correctly - * for every payload size (small requests are processed as a - * single chunk), so callers see the vespera Rust router's - * endpoints exactly as published in {@code openapi.json} with - * no special configuration. + * Works correctly for every payload size (small requests are + * processed as a single chunk). Selected by + * {@link SmartDispatchModeResolver} (the autoconfigured default + * since 0.2.0) for large or unknown-length bodies, and + * unconditionally by the conservative opt-out + * {@link BidirectionalStreamingDispatchModeResolver} + * ({@code vespera.bridge.dispatch-mode=bidirectional-streaming}, + * pre-0.2.0 default). */ BIDIRECTIONAL_STREAMING, + + /** + * Direct-buffer dispatch via + * {@link VesperaBridge#dispatchDirectPooled(byte[], boolean)} — + * eliminates the JNI region copies and per-call Java heap array + * allocations of {@link #SYNC}. + * + *

Selected by the autoconfigured + * {@link SmartDispatchModeResolver} (default since 0.2.0) for + * small, bounded, idempotent requests (GET/HEAD/PUT/DELETE/ + * OPTIONS with {@code Content-Length} absent or ≤ 256 KiB). + * The idempotency gate matters because a response that overflows + * the pooled direct buffer re-runs the Rust handler once. Never + * selected by the conservative opt-out + * {@link BidirectionalStreamingDispatchModeResolver}; large or + * unbounded bodies always belong on + * {@link #BIDIRECTIONAL_STREAMING}. + */ + DIRECT, } diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DispatchModeResolver.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DispatchModeResolver.java index b1949f29..1eebc0a9 100644 --- a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DispatchModeResolver.java +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DispatchModeResolver.java @@ -6,13 +6,20 @@ * Strategy for deciding which {@link DispatchMode} should serve an * incoming HTTP request. * - *

The autoconfigured default returns - * {@link DispatchMode#BIDIRECTIONAL_STREAMING} for every request, - * which works correctly across all payload sizes (small requests - * are processed as a single chunk) and keeps Spring endpoints - * aligned with the URLs published in vespera's {@code openapi.json} - * — no path-based mode selection that would diverge from the Rust - * router's view. + *

The autoconfigured default since vespera-bridge 0.2.0 is + * {@link SmartDispatchModeResolver}: small bounded idempotent + * requests take {@link DispatchMode#DIRECT} (~2.2 µs), small + * non-idempotent requests take {@link DispatchMode#SYNC} (~3.2 µs), + * everything else falls back to + * {@link DispatchMode#BIDIRECTIONAL_STREAMING} (~24 µs). Spring + * endpoints stay aligned with the URLs published in vespera's + * {@code openapi.json} either way — the mode is picked per request + * from request properties, not from the URL. + * + *

Restore the pre-0.2.0 default (every request that may carry a + * body streams both ways) with the conservative opt-out: + * {@code vespera.bridge.dispatch-mode=bidirectional-streaming} → + * {@link BidirectionalStreamingDispatchModeResolver}. * *

Users who want a mixed policy (e.g. {@link DispatchMode#SYNC} * for sub-KB JSON RPC, {@link DispatchMode#STREAMING} for paths @@ -33,4 +40,41 @@ public interface DispatchModeResolver { * @return non-null {@link DispatchMode} value */ DispatchMode resolveMode(HttpServletRequest request); + + /** + * {@code true} when the request provably carries no body, so the + * bidirectional request-pull plumbing (a blocking pull thread, a + * bounded channel, and per-chunk JNI crossings — measured at + * ~16 µs per request) would be pure overhead. + * + *

Detection is deliberately conservative: + *

    + *
  • {@code Content-Length: 0} — provably empty for any method + * and protocol.
  • + *
  • No {@code Content-Length}, no {@code Transfer-Encoding}, + * and the method is GET / HEAD / OPTIONS — per RFC 9112 + * §6.3 such an HTTP/1.1 request has no body. The method + * restriction keeps HTTP/2 safe (h2 has no + * {@code Transfer-Encoding} header, so a length-less POST + * body cannot be ruled out there).
  • + *
+ * + *

Even when this misjudges an exotic length-less GET-with-body + * (h2 only), correctness is preserved — the non-bidirectional + * modes read the servlet input stream fully and send the body + * inline; only the memory profile differs. + */ + static boolean definitelyBodyless(HttpServletRequest request) { + long contentLength = request.getContentLengthLong(); + if (contentLength == 0) { + return true; + } + if (contentLength > 0 || request.getHeader("Transfer-Encoding") != null) { + return false; + } + String method = request.getMethod(); + return "GET".equalsIgnoreCase(method) + || "HEAD".equalsIgnoreCase(method) + || "OPTIONS".equalsIgnoreCase(method); + } } diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/SmartDispatchModeResolver.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/SmartDispatchModeResolver.java new file mode 100644 index 00000000..d4a8e76d --- /dev/null +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/SmartDispatchModeResolver.java @@ -0,0 +1,92 @@ +package com.devfive.vespera.bridge; + +import jakarta.servlet.http.HttpServletRequest; + +import java.util.Locale; +import java.util.Set; + +/** + * Opt-in {@link DispatchModeResolver} that picks the cheapest safe + * JNI path per request (measured on a small {@code GET /health} + * round-trip: DIRECT 2.2 µs / SYNC 3.2 µs / bidirectional + * streaming 24.1 µs): + * + *

    + *
  • {@link DispatchMode#DIRECT} — small bounded + * (<= {@link #maxDirectBytes}) or provably bodyless requests + * with an idempotent method (GET / HEAD / PUT / DELETE / + * OPTIONS per RFC 9110). Idempotency matters because a DIRECT + * response overflow retries the dispatch, re-running the Rust + * handler.
  • + *
  • {@link DispatchMode#SYNC} — small bounded requests with a + * non-idempotent method (POST / PATCH). SYNC never re-runs + * the handler, so it is safe for any method; the response is + * fully buffered on the heap, which the size gate keeps + * reasonable for JSON-RPC-shaped traffic.
  • + *
  • {@link DispatchMode#BIDIRECTIONAL_STREAMING} — everything + * else (large or unknown-length bodies).
  • + *
+ * + *

Autoconfigured default since vespera-bridge 0.2.0. + * No property required — the autoconfigure module wires this resolver + * when no user {@code @Bean DispatchModeResolver} exists. Pin it + * explicitly with {@code vespera.bridge.dispatch-mode=smart}, or + * opt out to the pre-0.2.0 conservative default with + * {@code vespera.bridge.dispatch-mode=bidirectional-streaming} → + * {@link BidirectionalStreamingDispatchModeResolver}. Or register a + * custom resolver — {@code @ConditionalOnMissingBean} guarantees it + * wins over both: + * + *

{@code
+ * @Bean
+ * public DispatchModeResolver dispatchModeResolver() {
+ *     return new SmartDispatchModeResolver();
+ * }
+ * }
+ */ +public class SmartDispatchModeResolver implements DispatchModeResolver { + + private static final Set IDEMPOTENT_METHODS = + Set.of("GET", "HEAD", "PUT", "DELETE", "OPTIONS"); + + /** Default request-size gate: 256 KiB. */ + public static final long DEFAULT_MAX_DIRECT_BYTES = 256 * 1024L; + + private final long maxDirectBytes; + + public SmartDispatchModeResolver() { + this(DEFAULT_MAX_DIRECT_BYTES); + } + + /** + * @param maxDirectBytes largest {@code Content-Length} (bytes) + * eligible for DIRECT dispatch + */ + public SmartDispatchModeResolver(long maxDirectBytes) { + if (maxDirectBytes < 0) { + throw new IllegalArgumentException("maxDirectBytes must be >= 0"); + } + this.maxDirectBytes = maxDirectBytes; + } + + @Override + public DispatchMode resolveMode(HttpServletRequest request) { + long contentLength = request.getContentLengthLong(); + boolean smallBounded = contentLength >= 0 && contentLength <= maxDirectBytes; + // Bodyless requests fit the direct buffer by definition even + // when Content-Length is absent (the common shape of GET) — + // without this, every length-less GET missed the fast path. + boolean directSized = + smallBounded || DispatchModeResolver.definitelyBodyless(request); + if (!directSized) { + return DispatchMode.BIDIRECTIONAL_STREAMING; + } + String method = request.getMethod(); + if (method != null && IDEMPOTENT_METHODS.contains(method.toUpperCase(Locale.ROOT))) { + return DispatchMode.DIRECT; + } + // Small non-idempotent (POST / PATCH): SYNC never re-runs the + // handler — 7.5x cheaper than bidirectional for small bodies. + return smallBounded ? DispatchMode.SYNC : DispatchMode.BIDIRECTIONAL_STREAMING; + } +} diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridge.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridge.java index f4ce32d8..e5e62eea 100644 --- a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridge.java +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridge.java @@ -1,9 +1,12 @@ package com.devfive.vespera.bridge; -import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -14,8 +17,6 @@ import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -50,19 +51,41 @@ public class VesperaBridge { private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final JsonFactory JSON_FACTORY = MAPPER.getFactory(); private static final int WIRE_VERSION = 1; + /** + * Per-thread reusable byte buffer for {@link #serializeHeaderJson}. + * Reset (size cleared, capacity preserved) per call; only the + * buffer is pooled — a fresh {@link JsonGenerator} is created per + * call because generators bind to stream state. Virtual-thread + * caveat as {@link #DIRECT_POOL}: each vthread gets its own ~256 B + * buffer in Java 21+ and loses pooling until GC. + */ + private static final ThreadLocal HEADER_BUF = + ThreadLocal.withInitial(() -> new ByteArrayOutputStream(256)); + private static volatile boolean loaded = false; + private static volatile Integer pendingChunkBytes = null; + private static volatile Integer pendingChannelCapacity = null; + /** * Decoded wire-format response. * + *

The {@code body} component is a zero-copy, read-only + * {@link ByteBuffer} view over the original wire response array. + * Its position is {@code 0} and its limit is the body length. The + * view does not expose {@link ByteBuffer#array()} access, so callers + * that genuinely need an owned {@code byte[]} should use + * {@link #bodyBytes()}, which materialises a copy on demand. + * * @param status HTTP status code from the upstream router * @param headers response headers; each value is either a * {@link String} (single-valued) or a * {@link List List<String>} * (multi-valued, e.g. {@code set-cookie}) * @param metadata vespera metadata (e.g. {@code version}) - * @param body raw response body bytes + * @param body read-only raw response body view * @param validationErrors Vespera-validation failures hoisted from * a {@code 422} JSON body so callers can * read them without a second JSON parse. @@ -76,13 +99,61 @@ public record DecodedResponse( int status, Map headers, Map metadata, - byte[] body, - List> validationErrors) {} + ByteBuffer body, + List> validationErrors) { + + public DecodedResponse { + Objects.requireNonNull(body, "body"); + body = body.slice().asReadOnlyBuffer(); + } + + /** + * Return a fresh read-only duplicate of the response body view. + * The returned buffer is positioned at {@code 0} with + * {@code limit()} equal to the body length. + */ + @Override + public ByteBuffer body() { + return body.asReadOnlyBuffer(); + } + + /** + * Materialise the response body as an owned byte array. + * + *

This method copies the bytes from the zero-copy body view; + * use it at API boundaries that require {@code byte[]}. + */ + public byte[] bodyBytes() { + ByteBuffer view = body.asReadOnlyBuffer(); + byte[] bytes = new byte[view.remaining()]; + view.get(bytes); + return bytes; + } + } /** * Initialize the Rust engine. Tries bundled (JAR-embedded) first, * falls back to {@code java.library.path}. * + *

Streaming configuration is seeded from system properties + * before the first dispatch (values fixed for + * the process lifetime once read): + *

    + *
  • {@code vespera.streaming.chunkBytes} — per-chunk buffer + * size for streaming dispatches (default 256 KiB, clamped to + * 4 KiB – 8 MiB on the Rust side)
  • + *
  • {@code vespera.streaming.channelCapacity} — bound of the + * bidirectional request-body channel in slots (default 16, + * clamped to 1 – 1024)
  • + *
  • {@code vespera.runtime.workerThreads} — worker threads of + * the shared Tokio runtime (default: number of logical + * CPUs, clamped to 1 – 1024)
  • + *
+ * The {@code VESPERA_STREAMING_CHUNK_BYTES} / + * {@code VESPERA_STREAMING_CHANNEL_CAPACITY} / + * {@code VESPERA_RUNTIME_WORKERS} environment variables apply + * when no system property is set. + * * @param libraryName Cargo crate name (e.g. {@code "rust_jni_demo"}) */ public static synchronized void init(String libraryName) { @@ -92,9 +163,91 @@ public static synchronized void init(String libraryName) { } catch (UnsatisfiedLinkError e) { System.loadLibrary(libraryName); } + // Apply pending streaming config (set via configureStreaming before init). + // Pending values beat system properties (Rust-side setter > env > default). + try { + int chunkBytes = pendingChunkBytes != null + ? pendingChunkBytes + : Integer.getInteger("vespera.streaming.chunkBytes", 0); + int channelCapacity = pendingChannelCapacity != null + ? pendingChannelCapacity + : Integer.getInteger("vespera.streaming.channelCapacity", 0); + configureStreaming0(chunkBytes, channelCapacity); + } catch (UnsatisfiedLinkError olderNativeLibrary) { + // Pre-0.2 native libraries don't export configureStreaming0. + // Streaming config then falls back to env vars / defaults — + // never block init over an optional tuning hook. + } + try { + configureRuntime0(Integer.getInteger("vespera.runtime.workerThreads", 0)); + } catch (UnsatisfiedLinkError olderNativeLibrary) { + // Same guard as above — older native libraries fall back to + // the VESPERA_RUNTIME_WORKERS env var / Tokio's default. + } loaded = true; } + /** + * Configure streaming tuning parameters for the Rust-side dispatch + * engine. Call before {@link #init(String)} for + * guaranteed precedence (values are stored pending and applied right + * after the native library loads, before any dispatch); calling after + * init applies immediately. + * + *

Precedence (first hit wins, then process-fixed): this method > + * system properties ({@code vespera.streaming.chunkBytes} / + * {@code vespera.streaming.channelCapacity}) > environment variables + * ({@code VESPERA_STREAMING_CHUNK_BYTES} / + * {@code VESPERA_STREAMING_CHANNEL_CAPACITY}) > defaults + * (256 KiB chunk, 16 channel slots). + * + * @param chunkBytes per-chunk buffer size for streaming dispatches + * @param channelCapacity bound of the bidirectional request-body + * channel in slots + * @throws IllegalArgumentException if {@code chunkBytes} is outside + * [4096, 8388608] (4 KiB – 8 MiB) or {@code channelCapacity} + * is outside [1, 1024] + */ + public static synchronized void configureStreaming(int chunkBytes, int channelCapacity) { + if (chunkBytes < 4096 || chunkBytes > 8388608) { + throw new IllegalArgumentException( + "chunkBytes " + chunkBytes + + " out of range [4096, 8388608] (4 KiB – 8 MiB)"); + } + if (channelCapacity < 1 || channelCapacity > 1024) { + throw new IllegalArgumentException( + "channelCapacity " + channelCapacity + " out of range [1, 1024]"); + } + if (loaded) { + // Native library already loaded — apply immediately. + configureStreaming0(chunkBytes, channelCapacity); + } else { + // Native library not yet loaded — store pending values. + // These will be applied in init() before any dispatch. + pendingChunkBytes = chunkBytes; + pendingChannelCapacity = channelCapacity; + } + } + + /** + * Seed the Rust-side streaming configuration. Values {@code <= 0} + * leave the corresponding setting untouched (environment variable + * or built-in default applies). Calls after the configuration is + * fixed are silently ignored. + */ + private static native void configureStreaming0(int chunkBytes, int channelCapacity); + + /** + * Seed the shared Tokio runtime's worker thread count (system + * property {@code vespera.runtime.workerThreads}, env fallback + * {@code VESPERA_RUNTIME_WORKERS}; clamped to 1–1024 on the Rust + * side). Defaults to Tokio's heuristic (number of logical CPUs) + * — cap it when the JVM's own thread pools compete for the same + * cores. Values {@code <= 0} leave the setting untouched; calls + * after the runtime started are silently ignored. + */ + private static native void configureRuntime0(int workerThreads); + /** * Dispatch a wire-format HTTP-like request through the Rust axum * router (synchronous — blocks the calling @@ -184,7 +337,8 @@ public static CompletableFuture dispatch(byte[] wireRequest) { * with an empty {@code body} array. *

  • The request body bytes flow through {@code inputStream} * — Rust calls {@code inputStream.read(byte[])} repeatedly - * (16 KiB at a time) until EOF.
  • + * (256 KiB at a time by default; see + * {@code vespera.streaming.chunkBytes}) until EOF. *
  • The response body bytes flow through {@code outputStream} * — Rust calls {@code outputStream.write(byte[])} for each * axum body frame.
  • @@ -288,6 +442,404 @@ public static native void dispatchFullStreamingWithHeader( InputStream inputStream, OutputStream outputStream); + /** + * Thrown by {@link #dispatchDirectPooled(byte[], boolean)} when the + * response exceeds the out-buffer capacity and the caller disallowed + * automatic retry (non-idempotent requests). Carries the exact + * buffer size needed for a successful retry. + * + *

    Retrying re-runs the dispatch — the Rust + * handler executes again. Only retry idempotent requests + * (GET/HEAD/PUT/DELETE) automatically; for POST/PATCH the caller + * must decide. + */ + public static final class BufferTooSmallException extends RuntimeException { + private final int requiredSize; + + public BufferTooSmallException(int requiredSize) { + super("response requires a " + requiredSize + + "-byte direct out buffer; retry would re-run the dispatch"); + this.requiredSize = requiredSize; + } + + /** Exact out-buffer capacity needed for a successful retry. */ + public int requiredSize() { + return requiredSize; + } + } + + /** Initial per-thread direct buffer capacity (64 KiB). */ + private static final int DIRECT_INITIAL_CAPACITY = 64 * 1024; + + /** + * Maximum per-thread direct buffer capacity (default 4 MiB, + * overridable via the {@code vespera.direct.maxBufferBytes} system + * property). Payloads beyond the cap fall back to + * {@link #dispatchBytes(byte[])}. + */ + private static final int DIRECT_MAX_CAPACITY = Integer.getInteger( + "vespera.direct.maxBufferBytes", 4 * 1024 * 1024); + + /** + * Index 0 = request buffer, index 1 = response buffer. + * + *

    Virtual thread limitation: {@link ThreadLocal} + * binds to the virtual thread (not the carrier) in Java 21+. Each + * virtual thread gets its own pool, losing the pooling benefit in + * virtual-thread-per-request servers. See + * {@link #dispatchDirectPooled(byte[], boolean)} for mitigation. + */ + private static final ThreadLocal DIRECT_POOL = + ThreadLocal.withInitial(() -> new ByteBuffer[] { + ByteBuffer.allocateDirect(DIRECT_INITIAL_CAPACITY), + ByteBuffer.allocateDirect(DIRECT_INITIAL_CAPACITY)}); + + /** + * Handle to {@code Thread.isVirtual()} (final API since Java 21), + * resolved reflectively so this library still compiles and runs on + * the Java 17 baseline. {@code null} on pre-21 runtimes, where no + * thread is ever virtual. + */ + private static final java.lang.invoke.MethodHandle IS_VIRTUAL = resolveIsVirtual(); + + private static java.lang.invoke.MethodHandle resolveIsVirtual() { + try { + return java.lang.invoke.MethodHandles.lookup() + .findVirtual(Thread.class, "isVirtual", + java.lang.invoke.MethodType.methodType(boolean.class)); + } catch (ReflectiveOperationException pre21Runtime) { + return null; + } + } + + /** + * Whether the calling thread is a virtual thread (Java 21+); always + * {@code false} on the Java 17 baseline runtime. + * + *

    The pooled direct-buffer fast path is backed by + * {@link ThreadLocal}, which binds to the virtual thread + * (not its carrier) in Java 21+ — so on a virtual-thread-per-request + * server every dispatch would allocate a fresh direct buffer and + * accumulate off-heap memory until GC. {@link #dispatchDirectPooled} + * detects this and routes virtual threads to the GC-managed heap + * {@link #dispatchBytes(byte[])} path instead, automating the + * mitigation the docs previously left to manual configuration. + */ + private static boolean currentThreadIsVirtual() { + if (IS_VIRTUAL == null) { + return false; + } + try { + return (boolean) IS_VIRTUAL.invokeExact(Thread.currentThread()); + } catch (Throwable ignoredFallBackToPooled) { + return false; + } + } + + /** + * Raw native entry — validated by {@link #dispatchDirect(ByteBuffer, + * int, ByteBuffer)}; never call this directly. + */ + private static native int dispatchDirect0(ByteBuffer in, int inLen, ByteBuffer out); + + /** + * Direct-buffer synchronous dispatch — eliminates + * both JNI region copies ({@code byte[]} ↔ native) and the per-call + * Java heap array allocations of {@link #dispatchBytes(byte[])}. + * + *

    Contract (position/limit are IGNORED — the + * explicit {@code inLen} parameter is authoritative): + *

      + *
    • {@code in} and {@code out} MUST be direct buffers; + * heap buffers are rejected here, before crossing JNI.
    • + *
    • The wire request is read from absolute offsets + * {@code in[0..inLen]}.
    • + *
    • Return {@code >= 0}: a complete wire response occupies + * {@code out[0..n]}.
    • + *
    • Return {@code < 0}: {@code -(requiredSize)} — the response + * did not fit. {@code out} contents are undefined + * (the response streams directly into the buffer, so a + * prefix may have been written). {@code requiredSize} is + * exact; retrying re-runs the dispatch (see + * {@link BufferTooSmallException}).
    • + *
    • {@code Integer.MIN_VALUE}: response exceeds 2 GiB and is + * unrepresentable in this protocol.
    • + *
    + * + *

    The buffers are only accessed for the duration of this call; + * they may be reused immediately after it returns. + * + * @param in direct buffer holding the wire request at [0..inLen) + * @param inLen number of valid request bytes in {@code in} + * @param out direct buffer that receives the wire response + * @return bytes written, or the negative protocol codes above + * @throws IllegalArgumentException if either buffer is not direct, + * {@code inLen} is negative, or exceeds {@code in.capacity()} + */ + public static int dispatchDirect(ByteBuffer in, int inLen, ByteBuffer out) { + Objects.requireNonNull(in, "in"); + Objects.requireNonNull(out, "out"); + if (!in.isDirect() || !out.isDirect()) { + throw new IllegalArgumentException( + "dispatchDirect requires direct ByteBuffers (use ByteBuffer.allocateDirect)"); + } + if (inLen < 0 || inLen > in.capacity()) { + throw new IllegalArgumentException( + "inLen " + inLen + " out of range for in.capacity() " + in.capacity()); + } + return dispatchDirect0(in, inLen, out); + } + + /** + * Pooled convenience around {@link #dispatchDirect(ByteBuffer, int, + * ByteBuffer)} using per-thread reusable direct buffers (64 KiB + * initial, doubling up to {@code vespera.direct.maxBufferBytes}, + * default 4 MiB). + * + *

    Returns a read-only view of the thread-local + * response buffer covering exactly the wire response bytes. The + * view is valid only until the next {@code dispatchDirect*} call on + * the same thread — consume (or copy) it before dispatching again. + * + *

    Virtual thread (Project Loom) limitation: The + * per-thread buffer pool is backed by {@link ThreadLocal}, which + * binds to the virtual thread (not the carrier thread) in + * Java 21+ semantics. In a virtual-thread-per-request server, each + * virtual thread allocates a fresh direct buffer and loses all + * pooling benefit; direct memory accumulates until the virtual thread + * is garbage-collected. For virtual-thread deployments, prefer + * {@link #dispatchBytes(byte[])}, {@link #dispatchStreaming}, or + * {@link #dispatchFullStreaming}, or run dispatch on a bounded + * platform-thread executor, or lower {@code vespera.direct.maxBufferBytes}. + * + *

    Fallback / overflow policy: + *

      + *
    • Request larger than the cap → falls back to + * {@link #dispatchBytes(byte[])} (safe: no dispatch has run + * yet) and wraps the result.
    • + *
    • Response overflow with {@code retryOnOverflow == true} → + * grows the out buffer (or falls back to {@code dispatchBytes} + * beyond the cap) and dispatches again. The handler + * runs twice — only pass {@code true} for idempotent + * requests.
    • + *
    • Response overflow with {@code retryOnOverflow == false} → + * throws {@link BufferTooSmallException}.
    • + *
    + * + * @param wireRequest length-prefixed binary wire request + * @param retryOnOverflow whether a response overflow may re-run the + * dispatch (idempotent requests only) + * @return read-only buffer view of the wire response, positioned at + * 0 with {@code limit()} = response length + */ + public static ByteBuffer dispatchDirectPooled(byte[] wireRequest, boolean retryOnOverflow) { + Objects.requireNonNull(wireRequest, "wireRequest"); + if (currentThreadIsVirtual() || wireRequest.length > DIRECT_MAX_CAPACITY) { + // Virtual thread: the per-thread direct buffer pool would + // accumulate off-heap memory per vthread (ThreadLocal binds to + // the vthread, not the carrier) — use the GC-managed heap path. + // Oversized request (> cap): byte[] fallback is safe for any + // method because no dispatch has run yet. + return ByteBuffer.wrap(dispatchBytes(wireRequest)).asReadOnlyBuffer(); + } + ByteBuffer[] pool = DIRECT_POOL.get(); + if (pool[0].capacity() < wireRequest.length) { + pool[0] = ByteBuffer.allocateDirect(grownCapacity(wireRequest.length)); + } + ByteBuffer in = pool[0]; + in.clear(); + in.put(wireRequest); + + return dispatchViaPool(wireRequest.length, retryOnOverflow, () -> wireRequest); + } + + /** + * Encode-and-dispatch convenience that skips the intermediate + * wire-sized {@code byte[]} entirely: the wire request is encoded + * straight into the pooled direct in-buffer via + * {@link #encodeRequestInto}, so the body bytes are copied + * heap→direct exactly once (the {@code byte[]}-based overload + * assembles a full wire array first and then copies it again). + * + *

    Same pooling, fallback, overflow, and view-validity semantics + * as {@link #dispatchDirectPooled(byte[], boolean)}. Note the two + * distinct retry concepts: encoding growth (request bigger + * than the pooled buffer) happens before any dispatch and is always + * safe; response-overflow retry re-runs the Rust handler + * and is gated by {@code retryOnOverflow}. + * + *

    Virtual thread (Project Loom) limitation: The + * per-thread buffer pool is backed by {@link ThreadLocal}, which + * binds to the virtual thread (not the carrier thread) in + * Java 21+ semantics. In a virtual-thread-per-request server, each + * virtual thread allocates a fresh direct buffer and loses all + * pooling benefit; direct memory accumulates until the virtual thread + * is garbage-collected. For virtual-thread deployments, prefer + * {@link #dispatchBytes(byte[])}, {@link #dispatchStreaming}, or + * {@link #dispatchFullStreaming}, or run dispatch on a bounded + * platform-thread executor, or lower {@code vespera.direct.maxBufferBytes}. + * + * @param appName target app name (may be {@code null} for default) + * @param method HTTP method (uppercase) + * @param path URL path + * @param query raw query string (may be {@code null}) + * @param headers request headers + * @param body request body bytes (may be empty or {@code null}) + * @param retryOnOverflow whether a response overflow may re-run the + * dispatch (idempotent requests only) + * @return read-only buffer view of the wire response, valid until + * the next {@code dispatchDirect*} call on this thread + */ + public static ByteBuffer dispatchDirectPooled( + String appName, + String method, + String path, + String query, + Map headers, + byte[] body, + boolean retryOnOverflow) { + byte[] headerJson = serializeHeaderJson(appName, method, path, query, headers); + byte[] bodyBytes = body != null ? body : new byte[0]; + int total = 4 + headerJson.length + bodyBytes.length; + if (currentThreadIsVirtual() || total > DIRECT_MAX_CAPACITY) { + // Virtual thread: avoid the per-vthread off-heap direct buffer + // accumulation — use the GC-managed heap path. Oversized + // request (> cap): byte[] fallback is safe for any method + // because no dispatch has run yet. + return ByteBuffer.wrap(dispatchBytes(assembleWire(headerJson, bodyBytes))) + .asReadOnlyBuffer(); + } + ByteBuffer[] pool = DIRECT_POOL.get(); + if (pool[0].capacity() < total) { + pool[0] = ByteBuffer.allocateDirect(grownCapacity(total)); + } + int written = encodeRequestInto(headerJson, bodyBytes, pool[0]); + if (written != total) { + throw new IllegalStateException( + "encodeRequestInto wrote " + written + ", expected " + total); + } + return dispatchViaPool(total, retryOnOverflow, + () -> assembleWire(headerJson, bodyBytes)); + } + + /** + * Dispatch the request already prepared in the pooled in-buffer + * ({@code pool[0][0..reqLen]}) and apply the response-overflow + * policy. {@code wireFallback} supplies the equivalent wire bytes + * lazily — only materialised when a permitted retry exceeds the + * pool cap and must take the {@code dispatchBytes} path. + */ + private static ByteBuffer dispatchViaPool( + int reqLen, boolean retryOnOverflow, java.util.function.Supplier wireFallback) { + ByteBuffer[] pool = DIRECT_POOL.get(); + int n = dispatchDirect(pool[0], reqLen, pool[1]); + if (n < 0 && n != Integer.MIN_VALUE) { + int required = -n; + if (!retryOnOverflow) { + throw new BufferTooSmallException(required); + } + if (required > DIRECT_MAX_CAPACITY) { + // Retry permitted; beyond the pool cap use the byte[] path. + return ByteBuffer.wrap(dispatchBytes(wireFallback.get())).asReadOnlyBuffer(); + } + pool[1] = ByteBuffer.allocateDirect(grownCapacity(required)); + n = dispatchDirect(pool[0], reqLen, pool[1]); + } + if (n < 0 && n != Integer.MIN_VALUE) { + // A second overflow is legitimate: the retry re-ran the + // handler, and a non-deterministic handler may produce a + // larger response this time. Surface the new exact size + // instead of retrying unboundedly. + throw new BufferTooSmallException(-n); + } + if (n < 0) { + throw new IllegalStateException( + "dispatchDirect protocol violation: return code " + n + " after retry"); + } + ByteBuffer view = pool[1].asReadOnlyBuffer(); + view.position(0).limit(n); + return view; + } + + /** + * Encode a wire request directly into {@code target} + * starting at position 0 — no intermediate wire-sized {@code byte[]}. + * + *

    On success the wire bytes occupy {@code target[0..returned]} + * and {@code target}'s position is left at the end of the written + * region. If {@code target} is too small, returns + * {@code -(requiredSize)} and writes nothing. This is an + * encoding-side size signal: no dispatch has happened, so + * growing the buffer and retrying is always safe (unlike the + * response-overflow retry, which re-runs the handler). + * + * @param appName target app name (may be {@code null} for default) + * @param method HTTP method (uppercase) + * @param path URL path + * @param query raw query string (may be {@code null}) + * @param headers request headers + * @param body request body bytes (may be empty or {@code null}) + * @param target destination buffer (any kind; for the JNI direct + * path use {@code ByteBuffer.allocateDirect}) + * @return total bytes written ({@code >= 4}), or {@code -(required)} + */ + public static int encodeRequestInto( + String appName, + String method, + String path, + String query, + Map headers, + byte[] body, + ByteBuffer target) { + Objects.requireNonNull(target, "target"); + byte[] headerJson = serializeHeaderJson(appName, method, path, query, headers); + return encodeRequestInto(headerJson, body != null ? body : new byte[0], target); + } + + /** Internal: write {@code [u32 BE len | headerJson | body]} at position 0. */ + private static int encodeRequestInto(byte[] headerJson, byte[] body, ByteBuffer target) { + int total = 4 + headerJson.length + body.length; + if (target.capacity() < total) { + return -total; + } + target.clear(); + target.order(ByteOrder.BIG_ENDIAN); + target.putInt(headerJson.length); + target.put(headerJson); + if (body.length > 0) { + target.put(body); + } + return total; + } + + /** Internal: assemble a heap wire array from pre-serialised parts. */ + private static byte[] assembleWire(byte[] headerJson, byte[] body) { + int headerLen = headerJson.length; + byte[] wire = new byte[4 + headerLen + body.length]; + // Write the u32 BE length prefix directly — avoids the + // HeapByteBuffer wrapper object that + // ByteBuffer.allocate(...).array() allocates per request; the + // arraycopy intrinsics handle the header + body. Byte-identical + // to the prior ByteBuffer path. + wire[0] = (byte) (headerLen >>> 24); + wire[1] = (byte) (headerLen >>> 16); + wire[2] = (byte) (headerLen >>> 8); + wire[3] = (byte) headerLen; + System.arraycopy(headerJson, 0, wire, 4, headerLen); + System.arraycopy(body, 0, wire, 4 + headerLen, body.length); + return wire; + } + + /** Smallest power-of-two-ish growth ≥ {@code needed}, capped. */ + private static int grownCapacity(int needed) { + int cap = DIRECT_INITIAL_CAPACITY; + while (cap < needed) { + cap = Math.min(cap * 2, DIRECT_MAX_CAPACITY); + if (cap == DIRECT_MAX_CAPACITY) break; + } + return Math.max(cap, needed); + } + /** * Encode a request into the binary wire format. * @@ -333,36 +885,50 @@ public static byte[] encodeRequest( String query, Map headers, byte[] body) { - try { - ObjectNode header = MAPPER.createObjectNode(); - header.put("v", WIRE_VERSION); - header.put("method", method); - header.put("path", path); + byte[] headerJson = serializeHeaderJson(appName, method, path, query, headers); + return assembleWire(headerJson, body != null ? body : new byte[0]); + } + + /** + * Internal: serialise the wire request header JSON via Jackson's + * streaming {@link JsonGenerator} writing directly into the + * per-thread {@link #HEADER_BUF}. Byte-identical to the prior + * {@code createObjectNode() + writeValueAsBytes()} path: same + * field order ({@code v}, {@code method}, {@code path}, optional + * {@code query}/{@code headers}/{@code app}), same omission rules, + * same {@code UTF8JsonGenerator} emitter — the {@code ObjectNode} + * tree and {@code writeValueAsBytes} scratch buffer go away. + * (A 3-pass {@code StringBuilder} encoder was previously measured + * slower, 656 vs 487 ns/op; the generator writes bytes + * directly, so this rewrite keeps that win and drops the tree.) + */ + private static byte[] serializeHeaderJson(String appName, String method, + String path, String query, Map headers) { + ByteArrayOutputStream buf = HEADER_BUF.get(); + buf.reset(); + try (JsonGenerator gen = JSON_FACTORY.createGenerator(buf)) { + gen.writeStartObject(); + gen.writeNumberField("v", WIRE_VERSION); + gen.writeStringField("method", method); + gen.writeStringField("path", path); if (query != null && !query.isEmpty()) { - header.put("query", query); + gen.writeStringField("query", query); } if (headers != null && !headers.isEmpty()) { - ObjectNode hdrs = MAPPER.createObjectNode(); + gen.writeObjectFieldStart("headers"); for (Map.Entry e : headers.entrySet()) { - hdrs.put(e.getKey(), e.getValue()); + gen.writeStringField(e.getKey(), e.getValue()); } - header.set("headers", hdrs); + gen.writeEndObject(); } if (appName != null && !appName.isBlank()) { - header.put("app", appName.trim()); + gen.writeStringField("app", appName.trim()); } - byte[] headerJson = MAPPER.writeValueAsBytes(header); - byte[] bodyBytes = body != null ? body : new byte[0]; - ByteBuffer buf = ByteBuffer - .allocate(4 + headerJson.length + bodyBytes.length) - .order(ByteOrder.BIG_ENDIAN); - buf.putInt(headerJson.length); - buf.put(headerJson); - buf.put(bodyBytes); - return buf.array(); + gen.writeEndObject(); } catch (IOException e) { throw new IllegalStateException("encodeRequest serialisation failed", e); } + return buf.toByteArray(); } /** @@ -376,74 +942,73 @@ public static DecodedResponse decodeResponse(byte[] wire) { "wire response too short: " + (wire == null ? "null" : wire.length + " bytes")); } - ByteBuffer buf = ByteBuffer.wrap(wire).order(ByteOrder.BIG_ENDIAN); - int headerLen = buf.getInt(); + int headerLen = ((wire[0] & 0xFF) << 24) | ((wire[1] & 0xFF) << 16) + | ((wire[2] & 0xFF) << 8) | (wire[3] & 0xFF); if (headerLen < 0 || (long) 4 + headerLen > wire.length) { throw new IllegalArgumentException( "wire header_len " + headerLen + " overflows response (" + wire.length + " bytes)"); } - try { - JsonNode header = MAPPER.readTree( - new java.io.ByteArrayInputStream(wire, 4, headerLen)); - int status = header.path("status").asInt(500); - - Map headers = new LinkedHashMap<>(); - JsonNode hdrs = header.path("headers"); - if (hdrs.isObject()) { - Iterator> it = hdrs.fields(); - while (it.hasNext()) { - Map.Entry e = it.next(); - JsonNode v = e.getValue(); - if (v.isArray()) { - List list = new ArrayList<>(v.size()); - for (JsonNode item : v) { - list.add(item.asText()); + // Streaming decode via JsonParser (no JsonNode tree); defaults match + // the readTree path, unknown fields (incl. "v") are skipChildren'd. + int status = 500; + Map headers = null; + Map metadata = new LinkedHashMap<>(); + List> validationErrors = null; + try (JsonParser p = JSON_FACTORY.createParser(wire, 4, headerLen)) { + if (p.nextToken() == JsonToken.START_OBJECT) { + while (p.nextToken() == JsonToken.FIELD_NAME) { + String name = p.currentName(); + JsonToken t = p.nextToken(); + switch (name) { + case "status" -> status = p.getValueAsInt(500); + case "headers" -> { + if (t != JsonToken.START_OBJECT) { p.skipChildren(); break; } + while (p.nextToken() == JsonToken.FIELD_NAME) { + String k = p.currentName(); + if (headers == null) headers = new LinkedHashMap<>(); + if (p.nextToken() == JsonToken.START_ARRAY) { + List list = new ArrayList<>(); + while (p.nextToken() != JsonToken.END_ARRAY) list.add(p.getValueAsString()); + headers.put(k, list); + } else { + headers.put(k, p.getValueAsString()); + } + } } - headers.put(e.getKey(), list); - } else { - headers.put(e.getKey(), v.asText()); - } - } - } - - Map metadata = new LinkedHashMap<>(); - JsonNode mdNode = header.path("metadata"); - if (mdNode.isObject()) { - Iterator> it = mdNode.fields(); - while (it.hasNext()) { - Map.Entry e = it.next(); - metadata.put(e.getKey(), e.getValue().asText()); - } - } - - // Hoisted validation errors (Vespera Validated 422 path). - // null when absent (any non-422 or non-Vespera 422). - List> validationErrors = null; - JsonNode veNode = header.path("validation_errors"); - if (veNode.isArray()) { - validationErrors = new ArrayList<>(veNode.size()); - for (JsonNode item : veNode) { - Map entry = new LinkedHashMap<>(); - Iterator> it = item.fields(); - while (it.hasNext()) { - Map.Entry e = it.next(); - entry.put(e.getKey(), e.getValue().asText()); + case "metadata" -> { + if (t != JsonToken.START_OBJECT) { p.skipChildren(); break; } + while (p.nextToken() == JsonToken.FIELD_NAME) { + String k = p.currentName(); + p.nextToken(); + metadata.put(k, p.getValueAsString()); + } + } + case "validation_errors" -> { + if (t != JsonToken.START_ARRAY) { p.skipChildren(); break; } + validationErrors = new ArrayList<>(); + while (p.nextToken() == JsonToken.START_OBJECT) { + Map entry = new LinkedHashMap<>(); + while (p.nextToken() == JsonToken.FIELD_NAME) { + String k = p.currentName(); + p.nextToken(); + entry.put(k, p.getValueAsString()); + } + validationErrors.add(entry); + } + } + default -> p.skipChildren(); } - validationErrors.add(entry); } } - - int bodyStart = 4 + headerLen; - byte[] body = Arrays.copyOfRange(wire, bodyStart, wire.length); - return new DecodedResponse(status, headers, metadata, body, validationErrors); } catch (IOException e) { throw new IllegalArgumentException("wire header JSON parse failed", e); } + ByteBuffer body = ByteBuffer.wrap(wire, 4 + headerLen, wire.length - 4 - headerLen); + return new DecodedResponse( + status, headers == null ? Map.of() : headers, metadata, body, validationErrors); } - // --- Internal: bundled native lib extraction --- - private static void loadBundled(String libraryName) { String os = detectOs(); String arch = detectArch(); diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeAutoConfiguration.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeAutoConfiguration.java index 050fd9db..28198bee 100644 --- a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeAutoConfiguration.java +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeAutoConfiguration.java @@ -25,16 +25,31 @@ * register a {@code @Bean AppNameResolver} — * the default {@link HeaderAppNameResolver} is automatically * disabled. + *

  • Conservative dispatch mode (opt-out from smart): + * set {@code vespera.bridge.dispatch-mode=bidirectional-streaming} + * to restore the pre-0.2.0 default + * ({@link BidirectionalStreamingDispatchModeResolver}) — every + * request that may carry a body streams both ways. Use when + * you want maximally uniform handler invocation semantics and + * are willing to pay the ~24 µs/request streaming cost on + * small JSON-RPC payloads.
  • *
  • Custom dispatch mode policy: * register a {@code @Bean DispatchModeResolver} — - * the default - * {@link BidirectionalStreamingDispatchModeResolver} is + * the default {@link SmartDispatchModeResolver} is * automatically disabled.
  • *
  • Completely BYO controller: * set {@code vespera.bridge.controller-enabled=false} and * provide your own {@code @RestController} that calls the * {@link VesperaBridge} native methods directly.
  • * + * + *

    0.2.0 behavior change: the autoconfigured + * default {@link DispatchModeResolver} flipped from + * {@link BidirectionalStreamingDispatchModeResolver} to + * {@link SmartDispatchModeResolver}. Measured on a small {@code GET + * /health} round-trip through the real JNI boundary: DIRECT 2.2 µs / + * SYNC 3.2 µs vs the old bidirectional 24.1 µs. Restore the old + * behavior with {@code vespera.bridge.dispatch-mode=bidirectional-streaming}. */ @Configuration(proxyBeanMethods = false) @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) @@ -47,12 +62,58 @@ public AppNameResolver vesperaBridgeAppNameResolver(VesperaBridgeProperties prop return new HeaderAppNameResolver(props.getAppHeader()); } + /** + * Opt-out conservative dispatch mode: every request that may + * carry a body streams both ways + * ({@link BidirectionalStreamingDispatchModeResolver}). Restores + * the pre-0.2.0 default. + * + *

    Declared before the autoconfigured default so that + * {@code @ConditionalOnMissingBean} on the default skips when this + * one is created. Opt-in via + * {@code vespera.bridge.dispatch-mode=bidirectional-streaming}; + * the autoconfigured default is now + * {@link SmartDispatchModeResolver} because DIRECT/SYNC are + * 7–11× cheaper than streaming for small bounded requests + * (measured 2.2–3.2 µs vs 24.1 µs on a small {@code GET /health}). + */ @Bean + @ConditionalOnProperty( + prefix = "vespera.bridge", + name = "dispatch-mode", + havingValue = "bidirectional-streaming") @ConditionalOnMissingBean - public DispatchModeResolver vesperaBridgeDispatchModeResolver() { + public DispatchModeResolver vesperaBridgeBidirectionalStreamingDispatchModeResolver() { return new BidirectionalStreamingDispatchModeResolver(); } + /** + * Autoconfigured default since 0.2.0: + * {@link SmartDispatchModeResolver} picks per request — DIRECT + * (pooled direct buffers, no JNI array copies) for small/bodyless + * idempotent requests, SYNC for small non-idempotent requests, + * BIDIRECTIONAL_STREAMING for everything else. + * + *

    The two trade-offs callers accept on the new default: + *

      + *
    • DIRECT retries (re-runs the Rust handler) once when a + * response exceeds {@code vespera.direct.maxBufferBytes} + * (default 4 MiB). This is why DIRECT is restricted to + * idempotent methods (GET/HEAD/PUT/DELETE/OPTIONS).
    • + *
    • SYNC buffers the full response on the JVM heap. The + * 256 KiB request-size gate keeps the response size + * reasonable for JSON-RPC-shaped traffic.
    • + *
    + * + *

    Restore the pre-0.2.0 behavior with + * {@code vespera.bridge.dispatch-mode=bidirectional-streaming}. + */ + @Bean + @ConditionalOnMissingBean + public DispatchModeResolver vesperaBridgeDispatchModeResolver() { + return new SmartDispatchModeResolver(); + } + @Bean @ConditionalOnProperty( prefix = "vespera.bridge", diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeProperties.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeProperties.java index 76cae4f4..fe310d7e 100644 --- a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeProperties.java +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeProperties.java @@ -42,6 +42,32 @@ public class VesperaBridgeProperties { */ private boolean controllerEnabled = true; + /** + * Dispatch-mode policy for the autoconfigured proxy. + * + *

      + *
    • {@code smart} (default since 0.2.0) — small bounded + * idempotent requests (Content-Length known and ≤ 256 + * KiB; GET/HEAD/PUT/DELETE/OPTIONS) take the pooled + * direct-buffer path, skipping JNI array copies and + * per-request stream setup; small non-idempotent requests + * (POST/PATCH) take heap-buffered SYNC; everything else + * falls back to bidirectional streaming. Measured 2.2 µs + * (DIRECT) / 3.2 µs (SYNC) vs 24.1 µs (bidirectional) on + * a small {@code GET /health} round-trip. Trade-offs: + * DIRECT re-runs the handler when a response overflows the + * pooled buffer ({@code vespera.direct.maxBufferBytes}, + * default 4 MiB) — acceptable for idempotent requests + * only; SYNC fully buffers the response on the JVM heap.
    • + *
    • {@code bidirectional-streaming} — opt-out, restores the + * pre-0.2.0 default: every request that may carry a body + * streams both ways; safe for any payload size; the + * uniform per-request cost is ~24 µs even on small + * JSON-RPC payloads.
    • + *
    + */ + private String dispatchMode = "smart"; + public String getAppHeader() { return appHeader; } @@ -57,4 +83,12 @@ public boolean isControllerEnabled() { public void setControllerEnabled(boolean controllerEnabled) { this.controllerEnabled = controllerEnabled; } + + public String getDispatchMode() { + return dispatchMode; + } + + public void setDispatchMode(String dispatchMode) { + this.dispatchMode = dispatchMode; + } } diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaProxyController.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaProxyController.java index 9f472156..7ac2c525 100644 --- a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaProxyController.java +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaProxyController.java @@ -1,6 +1,5 @@ package com.devfive.vespera.bridge; -import com.devfive.vespera.bridge.VesperaBridge.DecodedResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; @@ -14,10 +13,11 @@ import java.io.IOException; import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; import java.nio.charset.StandardCharsets; import java.util.Enumeration; import java.util.LinkedHashMap; -import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; @@ -47,10 +47,15 @@ * * *

    The autoconfigured defaults ({@link HeaderAppNameResolver} on - * {@code X-Vespera-App} + - * {@link BidirectionalStreamingDispatchModeResolver}) keep the - * proxy transparent for every payload size. Replace either bean - * to change the policy without subclassing this controller. + * {@code X-Vespera-App} + {@link SmartDispatchModeResolver} since + * 0.2.0) keep the proxy transparent for every payload size while + * routing small bounded idempotent requests through the + * direct-buffer fast path (DIRECT 2.2 µs / SYNC 3.2 µs vs streaming + * 24.1 µs on a small {@code GET /health}). Restore the pre-0.2.0 + * bidirectional default with + * {@code vespera.bridge.dispatch-mode=bidirectional-streaming}, or + * replace either bean to change the policy without subclassing this + * controller. */ @RestController public class VesperaProxyController { @@ -95,9 +100,13 @@ public Object proxy(HttpServletRequest request, return dispatchAsyncFlow(appName, method, path, query, headers, readBody(request)); case STREAMING: - dispatchStreaming(request, response, appName, method, path, query, + dispatchStreaming(response, appName, method, path, query, headers, readBody(request)); return null; + case DIRECT: + dispatchDirectMode(response, appName, method, path, query, headers, + readBody(request)); + return null; case BIDIRECTIONAL_STREAMING: default: dispatchBidirectional(request, response, appName, method, path, query, headers); @@ -105,46 +114,39 @@ public Object proxy(HttpServletRequest request, } } - /** - * Fully read the servlet request body into a byte array. Used - * by sync / async / response-streaming modes (the bidirectional - * mode forwards the InputStream as-is). - */ + /** Shared empty body — avoids a {@code new byte[0]} per bodyless request. */ + private static final byte[] EMPTY_BODY = new byte[0]; + private static byte[] readBody(HttpServletRequest request) throws IOException { + // Bodyless requests (explicit Content-Length: 0 — e.g. the + // small/bodyless idempotent GETs the SmartDispatch resolver + // routes through DIRECT) skip the InputStream + readAllBytes + // allocations entirely. Chunked / unknown-length bodies + // (Content-Length == -1) still read through normally. + if (request.getContentLengthLong() == 0L) { + return EMPTY_BODY; + } try (InputStream in = request.getInputStream()) { return in.readAllBytes(); } } - // ── Mode handlers ───────────────────────────────────────────────── - - /** Sync — full request body materialised, full response materialised. */ private ResponseEntity dispatchSync( String appName, String method, String path, String query, Map headers, byte[] body) { - byte[] bodyBytes = body != null ? body : new byte[0]; byte[] wireReq = VesperaBridge.encodeRequest( - appName, method, path, query, headers, bodyBytes); + appName, method, path, query, headers, body); byte[] wireResp = VesperaBridge.dispatchBytes(wireReq); - DecodedResponse decoded = VesperaBridge.decodeResponse(wireResp); - return buildResponseEntity(decoded); + return buildResponseEntityFromWire(wireResp); } - /** - * Async — request body materialised, response delivered via a - * {@link CompletableFuture}. Spring MVC adapts the future - * automatically to its servlet-async machinery. - */ private CompletableFuture> dispatchAsyncFlow( String appName, String method, String path, String query, Map headers, byte[] body) { - byte[] bodyBytes = body != null ? body : new byte[0]; byte[] wireReq = VesperaBridge.encodeRequest( - appName, method, path, query, headers, bodyBytes); - return VesperaBridge.dispatch(wireReq).thenApply(wireResp -> { - DecodedResponse decoded = VesperaBridge.decodeResponse(wireResp); - return buildResponseEntity(decoded); - }); + appName, method, path, query, headers, body); + return VesperaBridge.dispatch(wireReq) + .thenApply(VesperaProxyController::buildResponseEntityFromWire); } /** @@ -154,12 +156,11 @@ private CompletableFuture> dispatchAsyncFlow( * first body byte hits the wire. */ private void dispatchStreaming( - HttpServletRequest request, HttpServletResponse response, + HttpServletResponse response, String appName, String method, String path, String query, Map headers, byte[] body) throws IOException { - byte[] bodyBytes = body != null ? body : new byte[0]; byte[] wireReq = VesperaBridge.encodeRequest( - appName, method, path, query, headers, bodyBytes); + appName, method, path, query, headers, body); VesperaBridge.dispatchStreamingWithHeader( wireReq, headerBytes -> applyDecodedHeader(headerBytes, response), @@ -188,18 +189,100 @@ private void dispatchBidirectional( response.getOutputStream().flush(); } - // ── Helpers ────────────────────────────────────────────────────── + /** + * Direct-buffer dispatch — request body materialised (DIRECT is + * gated to small bounded payloads by the resolver), response served + * from the pooled direct buffer without a {@code byte[]} + * materialisation: the header slice is decoded to commit + * status/headers, then the body region is channelled straight into + * the servlet output stream. + * + *

    Overflow retry (which re-runs the Rust handler) is permitted + * only for idempotent methods; for others a + * {@link VesperaBridge.BufferTooSmallException} surfaces as a + * {@code 500} with the required size — the controller never + * double-executes a non-idempotent handler. (The resolver should + * keep such requests off DIRECT in the first place.) + */ + private static void dispatchDirectMode( + HttpServletResponse response, + String appName, String method, String path, String query, + Map headers, byte[] body) throws IOException { + ByteBuffer wireResp; + try { + // Encodes straight into the pooled direct buffer — no + // intermediate wire-sized byte[]. + wireResp = VesperaBridge.dispatchDirectPooled( + appName, method, path, query, headers, body, isIdempotent(method)); + } catch (VesperaBridge.BufferTooSmallException overflow) { + // Non-idempotent + response larger than the pool: the first + // dispatch already ran; its result was discarded. Serving + // via dispatchBytes would run the handler a second time, so + // surface the size to the operator instead of silently + // double-executing. (The resolver should keep + // non-idempotent methods off DIRECT in the first place.) + response.setStatus(500); + response.getOutputStream().write( + ("vespera DIRECT overflow: response needs " + + overflow.requiredSize() + + " bytes; route this request via BIDIRECTIONAL_STREAMING") + .getBytes(StandardCharsets.UTF_8)); + response.getOutputStream().flush(); + return; + } + + // Commit status + headers parsed straight from the direct buffer — + // no byte[] copy, no DecodedResponse object graph (maps / metadata / + // body views). addHeader on the still-uncommitted response is + // equivalent to setHeader for a header's first value and appends for + // multi-valued headers (e.g. set-cookie). + int headerLen = wireResp.getInt(0); + WireHeaderReader.apply(wireResp, 4, headerLen, response::setStatus, response::addHeader); + + // Stream the body region of the direct buffer straight out. + wireResp.position(4 + headerLen); + if (wireResp.hasRemaining()) { + Channels.newChannel(response.getOutputStream()).write(wireResp); + } + response.getOutputStream().flush(); + } + + /** Idempotent per RFC 9110 — safe to re-run on DIRECT overflow retry. */ + private static boolean isIdempotent(String method) { + return switch (method == null ? "" : method.toUpperCase(Locale.ROOT)) { + case "GET", "HEAD", "PUT", "DELETE", "OPTIONS" -> true; + default -> false; + }; + } private static Map collectHeaders(HttpServletRequest request) { Map headers = new LinkedHashMap<>(); Enumeration names = request.getHeaderNames(); while (names.hasMoreElements()) { String name = names.nextElement(); - headers.put(name.toLowerCase(Locale.ROOT), request.getHeader(name)); + headers.put(toLowerCaseAscii(name), request.getHeader(name)); } return headers; } + /** + * Lowercase an HTTP header name without allocating when it is + * already lowercase — the common case, since HTTP/2 mandates + * lowercase field names and most HTTP/1.1 clients send canonical + * names. Header names are ASCII per RFC 9110 §5.1, so an ASCII + * scan is sufficient; only on encountering an uppercase letter do + * we fall back to a full {@link String#toLowerCase} copy. + */ + private static String toLowerCaseAscii(String name) { + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (c >= 'A' && c <= 'Z') { + return name.toLowerCase(Locale.ROOT); + } + } + return name; + } + /** * Apply a decoded wire header to {@link HttpServletResponse} — * called from streaming dispatch callbacks BEFORE the first body @@ -207,18 +290,18 @@ private static Map collectHeaders(HttpServletRequest request) { */ private static void applyDecodedHeader(byte[] headerBytes, HttpServletResponse response) { - DecodedResponse meta = VesperaBridge.decodeResponse(headerBytes); - response.setStatus(meta.status()); - for (Map.Entry entry : meta.headers().entrySet()) { - Object val = entry.getValue(); - if (val instanceof List list) { - for (Object v : list) { - response.addHeader(entry.getKey(), String.valueOf(v)); - } - } else if (val != null) { - response.setHeader(entry.getKey(), String.valueOf(val)); - } - } + // Apply status + headers straight from the wire header bytes via + // the allocation-lean WireHeaderReader — the same path + // dispatchDirectMode uses. This avoids the DecodedResponse object + // graph (headers map, the always-allocated metadata LinkedHashMap, + // and the body ByteBuffer view) that VesperaBridge.decodeResponse + // builds, on every streaming dispatch's header callback. + // addHeader on an uncommitted response equals setHeader for a + // header's first value and appends for multi-valued headers + // (e.g. set-cookie), preserving the prior semantics. + ByteBuffer buf = ByteBuffer.wrap(headerBytes); + int headerLen = buf.getInt(0); + WireHeaderReader.apply(buf, 4, headerLen, response::setStatus, response::addHeader); } /** @@ -227,40 +310,58 @@ private static void applyDecodedHeader(byte[] headerBytes, * {@link String} for text-like Content-Types, * {@code byte[]} otherwise. */ - private static ResponseEntity buildResponseEntity(DecodedResponse decoded) { - HttpHeaders httpHeaders = new HttpHeaders(); - for (Map.Entry entry : decoded.headers().entrySet()) { - Object val = entry.getValue(); - if (val instanceof List list) { - for (Object v : list) { - httpHeaders.add(entry.getKey(), String.valueOf(v)); - } - } else if (val != null) { - httpHeaders.set(entry.getKey(), String.valueOf(val)); - } + /** + * Build a {@link ResponseEntity} straight from the wire response + * {@code byte[]} with minimal allocation: + * + *

      + *
    • status + headers via the allocation-lean + * {@link WireHeaderReader} (parses directly to {@link HttpHeaders} — + * no {@code DecodedResponse} graph: no {@code metadata} map, no + * intermediate headers map, no body {@code ByteBuffer} views), and
    • + *
    • body sliced once straight from the wire tail — for text this + * drops the intermediate {@code byte[]} that {@code bodyBytes()} would + * allocate (a body-sized copy avoided per text response, scaling with + * payload).
    • + *
    + * + *

    {@link VesperaBridge#decodeResponse(byte[])} stays the public API for + * external/streaming consumers; this is a controller-internal fast path. + * Pure Java (no JNI) — safe to run on the async completion thread. + */ + private static ResponseEntity buildResponseEntityFromWire(byte[] wire) { + if (wire == null || wire.length < 4) { + throw new IllegalArgumentException( + "wire response too short: " + (wire == null ? "null" : wire.length + " bytes")); } - HttpStatus status = HttpStatus.valueOf(decoded.status()); - String contentType = httpHeaders.getFirst(HttpHeaders.CONTENT_TYPE); - if (isTextContentType(contentType)) { - String bodyStr = new String(decoded.body(), StandardCharsets.UTF_8); - return new ResponseEntity<>(bodyStr, httpHeaders, status); + int headerLen = ((wire[0] & 0xFF) << 24) | ((wire[1] & 0xFF) << 16) + | ((wire[2] & 0xFF) << 8) | (wire[3] & 0xFF); + if (headerLen < 0 || (long) 4 + headerLen > wire.length) { + throw new IllegalArgumentException( + "wire header_len " + headerLen + " overflows response (" + wire.length + " bytes)"); } - return new ResponseEntity<>(decoded.body(), httpHeaders, status); - } - - private static boolean isTextContentType(String ct) { - if (ct == null) return true; - String mime = ct.split(";", 2)[0].trim().toLowerCase(Locale.ROOT); - return mime.startsWith("text/") - || mime.equals("application/json") - || mime.endsWith("+json") - || mime.equals("application/xml") - || mime.endsWith("+xml") - || mime.equals("application/javascript") - || mime.equals("application/ecmascript") - || mime.equals("application/yaml") - || mime.equals("application/x-yaml") - || mime.equals("application/x-www-form-urlencoded") - || mime.equals("application/graphql"); + HttpHeaders httpHeaders = new HttpHeaders(); + int[] statusHolder = {500}; + WireHeaderReader.apply( + java.nio.ByteBuffer.wrap(wire), + 4, + headerLen, + s -> statusHolder[0] = s, + httpHeaders::add); + HttpStatus status = HttpStatus.valueOf(statusHolder[0]); + // Deliver the body as byte[] for every content type. The wire + // header already carries the exact Content-Type, and Spring's + // ByteArrayHttpMessageConverter writes it verbatim — so this + // drops, for text responses, both the intermediate String + // allocation AND the UTF-8 decode→re-encode round-trip that + // ResponseEntity performed (the StringHttpMessageConverter + // would re-encode the just-decoded String straight back to UTF-8). + // One body-sized slice copy remains: ResponseEntity needs + // an owned array. (BREAKING vs ≤0.2.0: text responses surface as + // ResponseEntity rather than ResponseEntity; the + // bytes on the wire are identical.) + int bodyOff = 4 + headerLen; + return new ResponseEntity<>( + java.util.Arrays.copyOfRange(wire, bodyOff, wire.length), httpHeaders, status); } } diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/WireHeaderReader.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/WireHeaderReader.java new file mode 100644 index 00000000..400b1570 --- /dev/null +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/WireHeaderReader.java @@ -0,0 +1,316 @@ +package com.devfive.vespera.bridge; + +import java.nio.ByteBuffer; +import java.util.function.BiConsumer; +import java.util.function.IntConsumer; + +/** + * Zero-copy reader for the response wire header, used by the DIRECT + * dispatch path to apply {@code status} + {@code headers} straight from + * the pooled direct {@link ByteBuffer} — no intermediate {@code byte[]} + * copy, no {@code DecodedResponse} object graph (maps / metadata / body + * views), no per-call allocation beyond the header-value {@link String}s + * the servlet API itself requires. + * + *

    Reads bytes via absolute {@link ByteBuffer#get(int)} so a + * direct buffer (no backing array, which {@code Jackson.createParser} + * cannot consume without a copy) is parsed in place. + * + *

    Not a general JSON validator: it assumes the well-formed, + * fixed-schema header produced by the Rust {@code serde_json} side. Only + * the quote / backslash / control escapes and raw UTF-8 that + * {@code serde_json} emits are handled. Unknown fields ({@code v}, + * {@code metadata}, {@code validation_errors}, …) are skipped. + */ +final class WireHeaderReader { + + private final ByteBuffer buf; + private int pos; + private final int end; + + private WireHeaderReader(ByteBuffer buf, int off, int len) { + this.buf = buf; + this.pos = off; + this.end = off + len; + } + + /** + * Parse the header JSON in {@code buf[off .. off+len]} and apply it: + * {@code statusSink} is invoked exactly once (default {@code 500} + * when the {@code status} field is absent, matching + * {@code decodeResponse}); {@code headerSink} is invoked once per + * header value (multiple times for multi-valued headers such as + * {@code set-cookie}). + */ + static void apply( + ByteBuffer buf, + int off, + int len, + IntConsumer statusSink, + BiConsumer headerSink) { + WireHeaderReader r = new WireHeaderReader(buf, off, len); + int status = 500; + if (r.peek() == '{') { + r.beginObject(); + String name; + while ((name = r.nextKey()) != null) { + switch (name) { + case "status" -> status = r.readInt(); + case "headers" -> { + if (r.isObjectStart()) { + r.beginObject(); + String k; + while ((k = r.nextKey()) != null) { + if (r.isArrayStart()) { + r.beginArray(); + while (r.hasNextElement()) { + headerSink.accept(k, r.readString()); + } + } else { + headerSink.accept(k, r.readString()); + } + } + } else { + r.skipValue(); + } + } + default -> r.skipValue(); + } + } + } + statusSink.accept(status); + } + + private void skipWs() { + while (pos < end) { + int c = buf.get(pos) & 0xFF; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + pos++; + } else { + break; + } + } + } + + private int cur() { + return pos < end ? buf.get(pos) & 0xFF : -1; + } + + int peek() { + skipWs(); + return cur(); + } + + private IllegalArgumentException err(String what) { + return new IllegalArgumentException("wire header JSON: " + what + " at offset " + pos); + } + + private void expect(char c) { + skipWs(); + if (cur() != c) { + throw err("expected '" + c + "'"); + } + pos++; + } + + void beginObject() { + expect('{'); + } + + /** Next member key, or {@code null} at object end (stateless across nesting). */ + String nextKey() { + skipWs(); + int c = cur(); + if (c == ',') { + pos++; + skipWs(); + c = cur(); + } + if (c == '}') { + pos++; + return null; + } + String key = readString(); + expect(':'); + return key; + } + + void beginArray() { + expect('['); + } + + boolean hasNextElement() { + skipWs(); + int c = cur(); + if (c == ',') { + pos++; + skipWs(); + c = cur(); + } + if (c == ']') { + pos++; + return false; + } + return true; + } + + boolean isObjectStart() { + return peek() == '{'; + } + + boolean isArrayStart() { + return peek() == '['; + } + + String readString() { + skipWs(); + if (cur() != '"') { + throw err("expected string"); + } + pos++; + StringBuilder sb = new StringBuilder(); + while (pos < end) { + int b = buf.get(pos++) & 0xFF; + if (b == '"') { + return sb.toString(); + } + if (b == '\\') { + if (pos >= end) { + throw err("dangling escape"); + } + int e = buf.get(pos++) & 0xFF; + switch (e) { + case '"' -> sb.append('"'); + case '\\' -> sb.append('\\'); + case '/' -> sb.append('/'); + case 'b' -> sb.append('\b'); + case 'f' -> sb.append('\f'); + case 'n' -> sb.append('\n'); + case 'r' -> sb.append('\r'); + case 't' -> sb.append('\t'); + case 'u' -> sb.append(readHex4()); + default -> throw err("bad escape"); + } + } else if (b < 0x80) { + sb.append((char) b); + } else if (b < 0xE0) { + sb.append((char) (((b & 0x1F) << 6) | nextCont())); + } else if (b < 0xF0) { + sb.append((char) (((b & 0x0F) << 12) | (nextCont() << 6) | nextCont())); + } else { + int cp = ((b & 0x07) << 18) | (nextCont() << 12) | (nextCont() << 6) | nextCont(); + sb.appendCodePoint(cp); + } + } + throw err("unterminated string"); + } + + private int nextCont() { + if (pos >= end) { + throw err("truncated UTF-8"); + } + return buf.get(pos++) & 0x3F; + } + + private char readHex4() { + if (pos + 4 > end) { + throw err("truncated unicode escape"); + } + int v = 0; + for (int k = 0; k < 4; k++) { + int d = buf.get(pos++) & 0xFF; + int h; + if (d >= '0' && d <= '9') { + h = d - '0'; + } else if (d >= 'a' && d <= 'f') { + h = d - 'a' + 10; + } else if (d >= 'A' && d <= 'F') { + h = d - 'A' + 10; + } else { + throw err("bad hex digit"); + } + v = (v << 4) | h; + } + return (char) v; + } + + int readInt() { + skipWs(); + int start = pos; + boolean neg = cur() == '-'; + if (neg) { + pos++; + } + boolean any = false; + long v = 0; + while (pos < end) { + int d = buf.get(pos) & 0xFF; + if (d < '0' || d > '9') { + break; + } + v = v * 10 + (d - '0'); + pos++; + any = true; + } + if (pos < end) { + int c = cur(); + if (c == '.' || c == 'e' || c == 'E') { + skipNumberTail(); + } + } + if (!any) { + pos = start; + throw err("expected number"); + } + return (int) (neg ? -v : v); + } + + private void skipNumberTail() { + while (pos < end) { + int d = buf.get(pos) & 0xFF; + if ((d >= '0' && d <= '9') || d == '.' || d == 'e' || d == 'E' || d == '+' || d == '-') { + pos++; + } else { + break; + } + } + } + + void skipValue() { + int c = peek(); + switch (c) { + case '{' -> { + beginObject(); + while (nextKey() != null) { + skipValue(); + } + } + case '[' -> { + beginArray(); + while (hasNextElement()) { + skipValue(); + } + } + case '"' -> readString(); + case 't', 'f', 'n' -> skipLiteral(); + default -> { + if (c == '-' || (c >= '0' && c <= '9')) { + readInt(); + } else { + throw err("unexpected value"); + } + } + } + } + + private void skipLiteral() { + while (pos < end) { + int d = buf.get(pos) & 0xFF; + if (d >= 'a' && d <= 'z') { + pos++; + } else { + break; + } + } + } +} diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/BidirectionalStreamingDispatchModeResolverTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/BidirectionalStreamingDispatchModeResolverTest.java new file mode 100644 index 00000000..f6e039a0 --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/BidirectionalStreamingDispatchModeResolverTest.java @@ -0,0 +1,54 @@ +package com.devfive.vespera.bridge; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +/** + * Gating tests for the default resolver's bodyless fast path: + * provably bodyless requests skip the bidirectional request-pull + * plumbing (response-only STREAMING, ~3x cheaper); anything that may + * carry a body keeps full bidirectional streaming. + */ +class BidirectionalStreamingDispatchModeResolverTest { + + private final BidirectionalStreamingDispatchModeResolver resolver = + new BidirectionalStreamingDispatchModeResolver(); + + @Test + void bodylessGetHeadOptionsUseResponseOnlyStreaming() { + for (String method : new String[] {"GET", "HEAD", "OPTIONS"}) { + MockHttpServletRequest req = new MockHttpServletRequest(method, "/x"); + assertEquals(DispatchMode.STREAMING, resolver.resolveMode(req), method); + } + } + + @Test + void explicitZeroContentLengthUsesResponseOnlyStreamingForAnyMethod() { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/x"); + req.setContent(new byte[0]); // Content-Length: 0 — provably empty. + assertEquals(DispatchMode.STREAMING, resolver.resolveMode(req)); + } + + @Test + void requestWithBodyKeepsBidirectionalStreaming() { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/x"); + req.setContent(new byte[64]); + assertEquals(DispatchMode.BIDIRECTIONAL_STREAMING, resolver.resolveMode(req)); + } + + @Test + void lengthlessPostKeepsBidirectionalStreaming() { + // No Content-Length on a method that may carry a body. + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/x"); + assertEquals(DispatchMode.BIDIRECTIONAL_STREAMING, resolver.resolveMode(req)); + } + + @Test + void chunkedGetKeepsBidirectionalStreaming() { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x"); + req.addHeader("Transfer-Encoding", "chunked"); + assertEquals(DispatchMode.BIDIRECTIONAL_STREAMING, resolver.resolveMode(req)); + } +} diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/ConfigureStreamingTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/ConfigureStreamingTest.java new file mode 100644 index 00000000..169a1375 --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/ConfigureStreamingTest.java @@ -0,0 +1,108 @@ +package com.devfive.vespera.bridge; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ConfigureStreamingTest { + + @Test + void preInitConfigurationStoresPending() { + assertDoesNotThrow(() -> VesperaBridge.configureStreaming(65536, 16)); + } + + @Test + void validChunkBytesAndCapacity() { + assertDoesNotThrow(() -> VesperaBridge.configureStreaming(65536, 16)); + } + + @Test + void chunkBytesMinBoundary() { + assertDoesNotThrow(() -> VesperaBridge.configureStreaming(4096, 16)); + } + + @Test + void chunkBytesMaxBoundary() { + assertDoesNotThrow(() -> VesperaBridge.configureStreaming(8388608, 16)); + } + + @Test + void chunkBytesBelowMinThrows() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.configureStreaming(4095, 16)); + assertTrue(ex.getMessage().contains("4095")); + assertTrue(ex.getMessage().contains("[4096, 8388608]")); + } + + @Test + void chunkBytesAboveMaxThrows() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.configureStreaming(8388609, 16)); + assertTrue(ex.getMessage().contains("8388609")); + assertTrue(ex.getMessage().contains("[4096, 8388608]")); + } + + @Test + void chunkBytesZeroThrows() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.configureStreaming(0, 16)); + assertTrue(ex.getMessage().contains("0")); + } + + @Test + void chunkBytesNegativeThrows() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.configureStreaming(-1, 16)); + assertTrue(ex.getMessage().contains("-1")); + } + + @Test + void capacityMinBoundary() { + assertDoesNotThrow(() -> VesperaBridge.configureStreaming(65536, 1)); + } + + @Test + void capacityMaxBoundary() { + assertDoesNotThrow(() -> VesperaBridge.configureStreaming(65536, 1024)); + } + + @Test + void capacityBelowMinThrows() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.configureStreaming(65536, 0)); + assertTrue(ex.getMessage().contains("0")); + assertTrue(ex.getMessage().contains("[1, 1024]")); + } + + @Test + void capacityAboveMaxThrows() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.configureStreaming(65536, 1025)); + assertTrue(ex.getMessage().contains("1025")); + assertTrue(ex.getMessage().contains("[1, 1024]")); + } + + @Test + void capacityNegativeThrows() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.configureStreaming(65536, -1)); + assertTrue(ex.getMessage().contains("-1")); + } + + @Test + void bothParametersOutOfRangeThrowsForChunkBytes() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.configureStreaming(0, 0)); + assertTrue(ex.getMessage().contains("chunkBytes")); + } +} diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/EncodeRequestIntoTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/EncodeRequestIntoTest.java new file mode 100644 index 00000000..45329298 --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/EncodeRequestIntoTest.java @@ -0,0 +1,95 @@ +package com.devfive.vespera.bridge; + +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pure-Java wire-equivalence tests: {@link VesperaBridge#encodeRequestInto} + * must produce byte-identical output to {@link VesperaBridge#encodeRequest} + * for the same inputs. No native library required. + */ +class EncodeRequestIntoTest { + + private static byte[] drain(ByteBuffer target, int len) { + byte[] out = new byte[len]; + target.get(0, out); + return out; + } + + private static void assertEquivalent( + String appName, String method, String path, String query, + Map headers, byte[] body) { + byte[] expected = VesperaBridge.encodeRequest( + appName, method, path, query, headers, body); + + ByteBuffer target = ByteBuffer.allocateDirect(expected.length + 16); + int written = VesperaBridge.encodeRequestInto( + appName, method, path, query, headers, body, target); + + assertEquals(expected.length, written, "written length"); + assertArrayEquals(expected, drain(target, written), + "encodeRequestInto must be byte-identical to encodeRequest"); + } + + @Test + void typicalPostWithBodyAndHeaders() { + assertEquivalent(null, "POST", "/echo", "a=1&b=2", + Map.of("content-type", "application/json"), + "{\"k\":42}".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void multiAppGetWithoutBody() { + assertEquivalent("admin", "GET", "/dashboard", null, Map.of(), null); + } + + @Test + void emptyBodyAndNullQuery() { + assertEquivalent(null, "DELETE", "/items/9", null, + Map.of("x-custom", "v"), new byte[0]); + } + + @Test + void binaryBodySurvivesVerbatim() { + byte[] binary = new byte[257]; + for (int i = 0; i < binary.length; i++) { + binary[i] = (byte) i; + } + assertEquivalent(null, "POST", "/upload", null, + Map.of("content-type", "application/octet-stream"), binary); + } + + @Test + void tooSmallTargetReturnsNegativeRequiredAndWritesNothing() { + byte[] body = "payload".getBytes(StandardCharsets.UTF_8); + byte[] expected = VesperaBridge.encodeRequest(null, "POST", "/x", null, Map.of(), body); + + ByteBuffer tiny = ByteBuffer.allocateDirect(8); + tiny.put(0, (byte) 0x7F); // sentinel byte to prove nothing was written + int rc = VesperaBridge.encodeRequestInto(null, "POST", "/x", null, Map.of(), body, tiny); + + assertEquals(-expected.length, rc, "must report exact required size, negated"); + assertEquals((byte) 0x7F, tiny.get(0), "target must be untouched on failure"); + } + + @Test + void heapTargetAlsoSupported() { + // encodeRequestInto is buffer-kind-agnostic (only the JNI + // dispatch requires direct buffers). + byte[] expected = VesperaBridge.encodeRequest(null, "GET", "/h", null, Map.of(), null); + ByteBuffer heap = ByteBuffer.allocate(expected.length); + int written = VesperaBridge.encodeRequestInto(null, "GET", "/h", null, Map.of(), null, heap); + assertEquals(expected.length, written); + assertTrue(heap.hasArray()); + byte[] out = new byte[written]; + heap.get(0, out); + assertArrayEquals(expected, out); + } +} diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/ResponseBodyBuildTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/ResponseBodyBuildTest.java new file mode 100644 index 00000000..4542703c --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/ResponseBodyBuildTest.java @@ -0,0 +1,272 @@ +package com.devfive.vespera.bridge; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.devfive.vespera.bridge.VesperaBridge.DecodedResponse; +import java.lang.management.ManagementFactory; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; + +/** + * Lever 1 gate: the controller builds the response body straight from the wire + * buffer ({@code Arrays.copyOfRange(wire, bodyOff, end)}) instead of {@code + * decoded.bodyBytes()}. Since the controller now unifies on {@code + * ResponseEntity} for every content type, the text helpers below + * ({@code new String(wire, off, len)}) remain as a byte-identity proof of the + * extraction offsets across the content/charset matrix — they are no longer the + * controller's delivery path, which slices to {@code byte[]} uniformly and so + * drops both the intermediate {@code byte[]} and the prior text-only UTF-8 + * decode→re-encode round-trip. + */ +class ResponseBodyBuildTest { + + /** Assemble a wire response {@code [u32 len | header | body]}. */ + private static byte[] wire(String contentType, byte[] body) { + String header = + contentType == null + ? "{\"v\":1,\"status\":200,\"headers\":{},\"metadata\":{\"version\":\"0.0.0\"}}" + : "{\"v\":1,\"status\":200,\"headers\":{\"content-type\":\"" + + contentType + + "\"},\"metadata\":{\"version\":\"0.0.0\"}}"; + byte[] hb = header.getBytes(StandardCharsets.UTF_8); + byte[] w = new byte[4 + hb.length + body.length]; + w[0] = (byte) (hb.length >>> 24); + w[1] = (byte) (hb.length >>> 16); + w[2] = (byte) (hb.length >>> 8); + w[3] = (byte) hb.length; + System.arraycopy(hb, 0, w, 4, hb.length); + System.arraycopy(body, 0, w, 4 + hb.length, body.length); + return w; + } + + // OLD: new String(decoded.bodyBytes(), UTF_8). NEW: new String(wire, off, len). + private static void assertTextEquivalent(byte[] body) { + byte[] w = wire("application/json", body); + DecodedResponse d = VesperaBridge.decodeResponse(w); + String oldStr = new String(d.bodyBytes(), StandardCharsets.UTF_8); + int bodyLen = d.body().remaining(); + int bodyOff = w.length - bodyLen; + String newStr = new String(w, bodyOff, bodyLen, StandardCharsets.UTF_8); + assertEquals(oldStr, newStr, "text body extraction must match the bodyBytes() path"); + } + + // OLD: decoded.bodyBytes(). NEW: Arrays.copyOfRange(wire, off, end). + private static void assertBinaryEquivalent(byte[] body) { + byte[] w = wire("application/octet-stream", body); + DecodedResponse d = VesperaBridge.decodeResponse(w); + byte[] oldB = d.bodyBytes(); + int bodyLen = d.body().remaining(); + int bodyOff = w.length - bodyLen; + byte[] newB = Arrays.copyOfRange(w, bodyOff, w.length); + assertArrayEquals(oldB, newB, "binary body extraction must match the bodyBytes() path"); + assertArrayEquals(body, newB, "binary body must round-trip exactly"); + } + + @Test + void textBodyMatrixIsByteIdentical() { + assertTextEquivalent("{\"ok\":true}".getBytes(StandardCharsets.UTF_8)); + assertTextEquivalent("plain ascii".getBytes(StandardCharsets.UTF_8)); + assertTextEquivalent("café — naïve — 日本語".getBytes(StandardCharsets.UTF_8)); + // 4-byte codepoint (emoji) — the multi-byte boundary case Metis flagged. + assertTextEquivalent("ok\uD83D\uDE80end".getBytes(StandardCharsets.UTF_8)); + assertTextEquivalent(new byte[0]); // empty + } + + @Test + void binaryBodyMatrixIsByteIdentical() { + byte[] allBytes = new byte[256]; + for (int i = 0; i < 256; i++) { + allBytes[i] = (byte) i; + } + assertBinaryEquivalent(allBytes); + assertBinaryEquivalent(new byte[0]); // empty + byte[] big = new byte[64 * 1024]; + new java.util.Random(7).nextBytes(big); + assertBinaryEquivalent(big); + } + + @Test + void isoLatin1BytesRoundTripViaUtf8DecodeUnchanged() { + // The controller decodes text as UTF-8 regardless of the charset + // parameter (pre-existing behavior). Confirm the new path preserves + // exactly that — same bytes in, same String out as the old path. + byte[] iso = {(byte) 0xE9, (byte) 0xE8, 'a', 'b'}; // é è in ISO-8859-1 + byte[] w = wire("text/plain; charset=ISO-8859-1", iso); + DecodedResponse d = VesperaBridge.decodeResponse(w); + String oldStr = new String(d.bodyBytes(), StandardCharsets.UTF_8); + int bodyLen = d.body().remaining(); + String newStr = new String(w, w.length - bodyLen, bodyLen, StandardCharsets.UTF_8); + assertEquals(oldStr, newStr); + } + + /** Allocation saving (bytes/op) — OLD bodyBytes()+String vs NEW direct String. */ + @Test + void allocationSavingScalesWithBodySize() throws Exception { + com.sun.management.ThreadMXBean tmx = + (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean(); + long tid = Thread.currentThread().getId(); + StringBuilder report = new StringBuilder(); + for (int kb : new int[] {1, 64, 1024}) { + byte[] body = new byte[kb * 1024]; + new java.util.Random(1).nextBytes(body); + // keep it valid-ish text by masking to ASCII so both paths decode identically + for (int i = 0; i < body.length; i++) { + body[i] = (byte) (body[i] & 0x7F); + } + byte[] w = wire("application/json", body); + + int warm = 2000; + int iters = 20000; + long blackhole = 0; + for (int i = 0; i < warm; i++) { + blackhole += oldText(w); + blackhole += newText(w); + } + long b0 = tmx.getThreadAllocatedBytes(tid); + for (int i = 0; i < iters; i++) blackhole += oldText(w); + long oldBytes = (tmx.getThreadAllocatedBytes(tid) - b0) / iters; + long b1 = tmx.getThreadAllocatedBytes(tid); + for (int i = 0; i < iters; i++) blackhole += newText(w); + long newBytes = (tmx.getThreadAllocatedBytes(tid) - b1) / iters; + report.append( + String.format( + "VESPERA_L1ALLOC body_kb=%d old_bytes=%d new_bytes=%d saved=%d (bh %d)%n", + kb, oldBytes, newBytes, oldBytes - newBytes, blackhole & 1)); + } + Files.writeString(Path.of(System.getProperty("java.io.tmpdir"), "vespera_l1alloc.txt"), report); + } + + private static int oldText(byte[] w) { + DecodedResponse d = VesperaBridge.decodeResponse(w); + return new String(d.bodyBytes(), StandardCharsets.UTF_8).length(); + } + + private static int newText(byte[] w) { + DecodedResponse d = VesperaBridge.decodeResponse(w); + int bodyLen = d.body().remaining(); + return new String(w, w.length - bodyLen, bodyLen, StandardCharsets.UTF_8).length(); + } + + // ---- Lever 2: lean status+headers parse (WireHeaderReader) vs decodeResponse graph ---- + + private static int headerLen(byte[] w) { + return ((w[0] & 0xFF) << 24) | ((w[1] & 0xFF) << 16) | ((w[2] & 0xFF) << 8) | (w[3] & 0xFF); + } + + /** OLD: decodeResponse graph → iterate headers map into HttpHeaders. */ + private static HttpHeaders oldHeaders(byte[] w) { + DecodedResponse d = VesperaBridge.decodeResponse(w); + HttpHeaders h = new HttpHeaders(); + for (var e : d.headers().entrySet()) { + Object v = e.getValue(); + if (v instanceof java.util.List list) { + for (Object x : list) { + h.add(e.getKey(), String.valueOf(x)); + } + } else if (v != null) { + h.set(e.getKey(), String.valueOf(v)); + } + } + return h; + } + + /** NEW: lean WireHeaderReader straight into HttpHeaders. */ + private static HttpHeaders leanHeaders(byte[] w, int[] status) { + HttpHeaders h = new HttpHeaders(); + WireHeaderReader.apply( + java.nio.ByteBuffer.wrap(w), 4, headerLen(w), s -> status[0] = s, h::add); + return h; + } + + @Test + void leanStatusAndHeadersMatchDecodeResponse() { + // single-value header + byte[] w1 = wire("application/json", "{\"x\":1}".getBytes(StandardCharsets.UTF_8)); + DecodedResponse d1 = VesperaBridge.decodeResponse(w1); + int[] s1 = {-1}; + assertEquals(d1.status(), leanHeaders(w1, s1) == null ? -1 : s1[0]); + assertEquals(oldHeaders(w1), leanHeaders(w1, new int[1])); + // multi-value (set-cookie) + status + String hdr = + "{\"v\":1,\"status\":201,\"headers\":{\"set-cookie\":[\"a=1\",\"b=2\"]," + + "\"content-type\":\"application/json\"},\"metadata\":{\"version\":\"x\"}}"; + byte[] hb = hdr.getBytes(StandardCharsets.UTF_8); + byte[] w2 = new byte[4 + hb.length]; + w2[0] = (byte) (hb.length >>> 24); + w2[1] = (byte) (hb.length >>> 16); + w2[2] = (byte) (hb.length >>> 8); + w2[3] = (byte) hb.length; + System.arraycopy(hb, 0, w2, 4, hb.length); + int[] s2 = {-1}; + HttpHeaders lean2 = leanHeaders(w2, s2); + assertEquals(201, s2[0]); + assertEquals(oldHeaders(w2), lean2); + } + + /** OLD full response build (decodeResponse graph + bodyBytes+String). */ + private static int oldFull(byte[] w) { + DecodedResponse d = VesperaBridge.decodeResponse(w); + HttpHeaders h = new HttpHeaders(); + for (var e : d.headers().entrySet()) { + if (e.getValue() != null) { + h.add(e.getKey(), String.valueOf(e.getValue())); + } + } + return d.status() + h.size() + new String(d.bodyBytes(), StandardCharsets.UTF_8).length(); + } + + /** + * NEW full response build (lean reader + body-from-wire) — + * buildResponseEntityFromWire logic. Since the controller now unifies + * on {@code ResponseEntity} for every content type (dropping + * the text-only {@code new String} branch and its UTF-8 + * decode→re-encode round-trip), the body is modelled as the + * {@code Arrays.copyOfRange} slice the controller actually returns. + */ + private static int newFull(byte[] w) { + int hl = headerLen(w); + HttpHeaders h = new HttpHeaders(); + int[] st = {500}; + WireHeaderReader.apply(java.nio.ByteBuffer.wrap(w), 4, hl, s -> st[0] = s, h::add); + int bodyOff = 4 + hl; + return st[0] + h.size() + Arrays.copyOfRange(w, bodyOff, w.length).length; + } + + @Test + void combinedAllocationSaving() throws Exception { + com.sun.management.ThreadMXBean tmx = + (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean(); + long tid = Thread.currentThread().getId(); + StringBuilder report = new StringBuilder(); + for (int kb : new int[] {0, 1, 64}) { + byte[] body = new byte[kb * 1024]; + for (int i = 0; i < body.length; i++) { + body[i] = (byte) ('a' + (i % 26)); + } + byte[] w = wire("application/json", body); + int warm = 2000; + int iters = 20000; + long bh = 0; + for (int i = 0; i < warm; i++) { + bh += oldFull(w); + bh += newFull(w); + } + long b0 = tmx.getThreadAllocatedBytes(tid); + for (int i = 0; i < iters; i++) bh += oldFull(w); + long oldB = (tmx.getThreadAllocatedBytes(tid) - b0) / iters; + long b1 = tmx.getThreadAllocatedBytes(tid); + for (int i = 0; i < iters; i++) bh += newFull(w); + long newB = (tmx.getThreadAllocatedBytes(tid) - b1) / iters; + report.append( + String.format( + "VESPERA_L2ALLOC body_kb=%d old_bytes=%d new_bytes=%d saved=%d (bh %d)%n", + kb, oldB, newB, oldB - newB, bh & 1)); + } + Files.writeString(Path.of(System.getProperty("java.io.tmpdir"), "vespera_l2alloc.txt"), report); + } +} diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/SmartDispatchModeResolverTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/SmartDispatchModeResolverTest.java new file mode 100644 index 00000000..8dfc338a --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/SmartDispatchModeResolverTest.java @@ -0,0 +1,94 @@ +package com.devfive.vespera.bridge; + +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** Pure-Java gating tests for {@link SmartDispatchModeResolver}. */ +class SmartDispatchModeResolverTest { + + private final SmartDispatchModeResolver resolver = new SmartDispatchModeResolver(); + + private static HttpServletRequest request(String method, long contentLength) { + MockHttpServletRequest req = new MockHttpServletRequest(method, "/x"); + if (contentLength >= 0) { + // MockHttpServletRequest derives getContentLengthLong() from + // the content array length, not the header. + req.setContent(new byte[(int) contentLength]); + } + return req; + } + + @Test + void smallIdempotentRequestUsesDirect() { + assertEquals(DispatchMode.DIRECT, + resolver.resolveMode(request("GET", 128))); + assertEquals(DispatchMode.DIRECT, + resolver.resolveMode(request("DELETE", 0))); + assertEquals(DispatchMode.DIRECT, + resolver.resolveMode(request("PUT", + SmartDispatchModeResolver.DEFAULT_MAX_DIRECT_BYTES))); + } + + @Test + void smallNonIdempotentRequestsUseSyncNeverDirect() { + // SYNC never re-runs the handler — safe for POST/PATCH, and + // 7.5x cheaper than bidirectional streaming for small bodies. + assertEquals(DispatchMode.SYNC, + resolver.resolveMode(request("POST", 128))); + assertEquals(DispatchMode.SYNC, + resolver.resolveMode(request("PATCH", 128))); + } + + @Test + void bodylessGetWithoutContentLengthUsesDirect() { + // The common GET shape: no body, no Content-Length header. + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x"); + assertEquals(DispatchMode.DIRECT, resolver.resolveMode(req)); + } + + @Test + void chunkedTransferEncodingFallsBackToStreaming() { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x"); + req.addHeader("Transfer-Encoding", "chunked"); + assertEquals(DispatchMode.BIDIRECTIONAL_STREAMING, resolver.resolveMode(req)); + } + + @Test + void lengthlessNonIdempotentFallsBackToStreaming() { + // POST without Content-Length: body cannot be ruled out. + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/x"); + assertEquals(DispatchMode.BIDIRECTIONAL_STREAMING, resolver.resolveMode(req)); + } + + @Test + void oversizedNonIdempotentFallsBackToStreaming() { + assertEquals(DispatchMode.BIDIRECTIONAL_STREAMING, + resolver.resolveMode(request("POST", + SmartDispatchModeResolver.DEFAULT_MAX_DIRECT_BYTES + 1))); + } + + @Test + void oversizedRequestFallsBackToStreaming() { + assertEquals(DispatchMode.BIDIRECTIONAL_STREAMING, + resolver.resolveMode(request("GET", + SmartDispatchModeResolver.DEFAULT_MAX_DIRECT_BYTES + 1))); + } + + @Test + void customCapIsHonoured() { + SmartDispatchModeResolver tight = new SmartDispatchModeResolver(64); + assertEquals(DispatchMode.DIRECT, tight.resolveMode(request("GET", 64))); + assertEquals(DispatchMode.BIDIRECTIONAL_STREAMING, + tight.resolveMode(request("GET", 65))); + } + + @Test + void negativeCapRejected() { + assertThrows(IllegalArgumentException.class, + () -> new SmartDispatchModeResolver(-1)); + } +} diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaBridgeAutoConfigurationTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaBridgeAutoConfigurationTest.java new file mode 100644 index 00000000..dfdcd190 --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaBridgeAutoConfigurationTest.java @@ -0,0 +1,111 @@ +package com.devfive.vespera.bridge; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Autoconfigure branch tests for the dispatch-mode policy beans. + * + *

    The contract under test (0.2.0 default flip): the autoconfigured + * default is {@link SmartDispatchModeResolver} (DIRECT/SYNC fast paths + * for small bounded requests, measured 2.2–3.2 µs vs 24.1 µs); + * {@code vespera.bridge.dispatch-mode=bidirectional-streaming} opts out + * to {@link BidirectionalStreamingDispatchModeResolver} (pre-0.2.0 + * behavior); {@code vespera.bridge.dispatch-mode=smart} explicitly + * pins the new default; a user-supplied bean always wins over all of + * the above via {@code @ConditionalOnMissingBean}. + */ +class VesperaBridgeAutoConfigurationTest { + + // withConfiguration (not withUserConfiguration): autoconfigurations + // must be evaluated AFTER user configs so @ConditionalOnMissingBean + // sees user-supplied beans — same ordering as a real Boot app. + private final WebApplicationContextRunner runner = + new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(VesperaBridgeAutoConfiguration.class)); + + @Test + void defaultResolverIsSmart() { + runner.run( + ctx -> + assertInstanceOf( + SmartDispatchModeResolver.class, + ctx.getBean(DispatchModeResolver.class), + "0.2.0: autoconfigured default flipped to SmartDispatchModeResolver")); + } + + @Test + void smartPropertyExplicitlyPinsSmartResolver() { + runner.withPropertyValues("vespera.bridge.dispatch-mode=smart") + .run( + ctx -> + assertInstanceOf( + SmartDispatchModeResolver.class, + ctx.getBean(DispatchModeResolver.class), + "explicit dispatch-mode=smart must keep the new default")); + } + + @Test + void bidirectionalStreamingPropertyOptsOutToStreamingResolver() { + runner.withPropertyValues("vespera.bridge.dispatch-mode=bidirectional-streaming") + .run( + ctx -> + assertInstanceOf( + BidirectionalStreamingDispatchModeResolver.class, + ctx.getBean(DispatchModeResolver.class), + "dispatch-mode=bidirectional-streaming must restore the" + + " pre-0.2.0 default")); + } + + @Test + void userBeanWinsOverDefault() { + runner.withUserConfiguration(CustomResolverConfig.class) + .run( + ctx -> + assertInstanceOf( + CustomResolver.class, + ctx.getBean(DispatchModeResolver.class), + "@ConditionalOnMissingBean: user bean must win over the" + + " autoconfigured smart default")); + } + + @Test + void userBeanWinsOverBidirectionalStreamingProperty() { + runner.withPropertyValues("vespera.bridge.dispatch-mode=bidirectional-streaming") + .withUserConfiguration(CustomResolverConfig.class) + .run( + ctx -> + assertInstanceOf( + CustomResolver.class, + ctx.getBean(DispatchModeResolver.class), + "@ConditionalOnMissingBean: user bean must win even when" + + " the opt-out property is set")); + } + + @Test + void controllerDisabledPropertyStillWorks() { + runner.withPropertyValues("vespera.bridge.controller-enabled=false") + .run(ctx -> assertTrue(ctx.getBeansOfType(VesperaProxyController.class).isEmpty())); + } + + static final class CustomResolver implements DispatchModeResolver { + @Override + public DispatchMode resolveMode(jakarta.servlet.http.HttpServletRequest request) { + return DispatchMode.SYNC; + } + } + + @Configuration(proxyBeanMethods = false) + static class CustomResolverConfig { + @Bean + DispatchModeResolver customResolver() { + return new CustomResolver(); + } + } +} diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaDirectWrapperTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaDirectWrapperTest.java new file mode 100644 index 00000000..5870a3be --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaDirectWrapperTest.java @@ -0,0 +1,71 @@ +package com.devfive.vespera.bridge; + +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pure-Java tests for the {@code dispatchDirect} wrapper's pre-JNI + * validation — no native library is loaded. Every rejection asserted + * here MUST happen before the native method is invoked; if validation + * regressed and the call crossed JNI, these tests would fail with + * {@link UnsatisfiedLinkError} instead of the expected exception. + */ +class VesperaDirectWrapperTest { + + private static final ByteBuffer DIRECT = ByteBuffer.allocateDirect(64); + private static final ByteBuffer HEAP = ByteBuffer.allocate(64); + + @Test + void heapInBufferRejectedBeforeJni() { + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.dispatchDirect(HEAP, 4, DIRECT)); + assertTrue(e.getMessage().contains("direct"), e.getMessage()); + } + + @Test + void heapOutBufferRejectedBeforeJni() { + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.dispatchDirect(DIRECT, 4, HEAP)); + assertTrue(e.getMessage().contains("direct"), e.getMessage()); + } + + @Test + void nullBuffersRejected() { + assertThrows(NullPointerException.class, + () -> VesperaBridge.dispatchDirect(null, 0, DIRECT)); + assertThrows(NullPointerException.class, + () -> VesperaBridge.dispatchDirect(DIRECT, 0, null)); + } + + @Test + void negativeInLenRejected() { + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.dispatchDirect(DIRECT, -1, DIRECT)); + assertTrue(e.getMessage().contains("inLen"), e.getMessage()); + } + + @Test + void inLenBeyondCapacityRejected() { + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.dispatchDirect(DIRECT, DIRECT.capacity() + 1, DIRECT)); + assertTrue(e.getMessage().contains("inLen"), e.getMessage()); + } + + @Test + void bufferTooSmallExceptionCarriesRequiredSize() { + VesperaBridge.BufferTooSmallException e = + new VesperaBridge.BufferTooSmallException(123_456); + assertEquals(123_456, e.requiredSize()); + assertTrue(e.getMessage().contains("123456"), e.getMessage()); + assertTrue(e.getMessage().contains("re-run"), e.getMessage()); + } +} diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaWireTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaWireTest.java index 9a6569bf..6717f83b 100644 --- a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaWireTest.java +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaWireTest.java @@ -133,7 +133,11 @@ void decodeResponse_parses_status_headers_and_body() throws Exception { assertEquals("text/plain; charset=utf-8", decoded.headers().get("content-type")); assertEquals("0.1.51", decoded.metadata().get("version")); assertEquals("I'm a teapot", - new String(decoded.body(), StandardCharsets.UTF_8)); + new String(decoded.bodyBytes(), StandardCharsets.UTF_8)); + assertTrue(decoded.body().isReadOnly(), "body view must be read-only"); + assertEquals(0, decoded.body().position(), "body view position must start at 0"); + assertEquals("I'm a teapot".length(), decoded.body().limit(), + "body view limit must equal body length"); } @Test @@ -167,7 +171,7 @@ void roundtrip_preserves_binary_body_byte_for_byte() throws Exception { DecodedResponse decoded = VesperaBridge.decodeResponse(wire); assertEquals(200, decoded.status()); - assertArrayEquals(payload, decoded.body(), + assertArrayEquals(payload, decoded.bodyBytes(), "binary body must round-trip byte-for-byte"); } @@ -202,7 +206,7 @@ void decodeResponse_hoists_validation_errors_when_present() throws Exception { // Body still preserved alongside the hoisted header field: assertArrayEquals( "{\"errors\":[...]}".getBytes(StandardCharsets.UTF_8), - decoded.body(), + decoded.bodyBytes(), "body must be preserved verbatim even when errors are hoisted"); } @@ -233,6 +237,6 @@ void encode_decode_full_request_roundtrip_via_synthetic_response() throws Except byte[] respWire = buildWireResponse(200, "text/plain", echoedBody); DecodedResponse decoded = VesperaBridge.decodeResponse(respWire); - assertArrayEquals(reqBody, decoded.body()); + assertArrayEquals(reqBody, decoded.bodyBytes()); } } diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/WireHeaderReaderTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/WireHeaderReaderTest.java new file mode 100644 index 00000000..f7536b0a --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/WireHeaderReaderTest.java @@ -0,0 +1,91 @@ +package com.devfive.vespera.bridge; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Correctness gate for the zero-copy DIRECT-path header reader. */ +class WireHeaderReaderTest { + + private record Captured(int status, List headers) {} + + /** Parse {@code headerJson} from a direct buffer laid out as the wire is. */ + private static Captured run(String headerJson) { + byte[] hb = headerJson.getBytes(StandardCharsets.UTF_8); + ByteBuffer buf = ByteBuffer.allocateDirect(4 + hb.length); + buf.putInt(hb.length); + buf.put(hb); + int[] status = {-1}; + List headers = new ArrayList<>(); + WireHeaderReader.apply( + buf, 4, hb.length, s -> status[0] = s, (k, v) -> headers.add(k + "=" + v)); + return new Captured(status[0], headers); + } + + @Test + void parsesStatusAndSingleHeader() { + Captured c = + run( + "{\"v\":1,\"status\":200,\"headers\":{\"content-type\":\"text/plain\"}," + + "\"metadata\":{\"version\":\"0.1.0\"}}"); + assertEquals(200, c.status()); + assertEquals(List.of("content-type=text/plain"), c.headers()); + } + + @Test + void parsesMultiValuedHeaderArray() { + Captured c = + run( + "{\"v\":1,\"status\":201,\"headers\":{\"set-cookie\":[\"a=1\",\"b=2\"]," + + "\"x\":\"y\"}}"); + assertEquals(201, c.status()); + assertEquals(List.of("set-cookie=a=1", "set-cookie=b=2", "x=y"), c.headers()); + } + + @Test + void handlesEscapesAndUtf8InValues() { + Captured c = + run( + "{\"status\":200,\"headers\":{\"x-q\":\"a\\\"b\\\\c\\n\",\"x-u\":\"caf\u00e9\"}}"); + assertEquals(200, c.status()); + assertEquals(List.of("x-q=a\"b\\c\n", "x-u=caf\u00e9"), c.headers()); + } + + @Test + void statusAbsentDefaultsTo500() { + Captured c = run("{\"v\":1,\"headers\":{\"a\":\"b\"}}"); + assertEquals(500, c.status()); + assertEquals(List.of("a=b"), c.headers()); + } + + @Test + void emptyHeadersAndEmptyMetadataDoNotCorruptParsing() { + // The exact shape (empty nested object before another field) that broke + // a prior stateful reader. + Captured c = run("{\"v\":1,\"status\":204,\"headers\":{},\"metadata\":{}}"); + assertEquals(204, c.status()); + assertEquals(List.of(), c.headers()); + } + + @Test + void skipsUnknownNestedAndArrayFields() { + Captured c = + run( + "{\"status\":422,\"validation_errors\":[{\"path\":\"a\",\"message\":\"m\"}]," + + "\"headers\":{\"content-type\":\"application/json\"}}"); + assertEquals(422, c.status()); + assertEquals(List.of("content-type=application/json"), c.headers()); + } + + @Test + void nonObjectHeaderIsSkipped() { + Captured c = run("{\"status\":200,\"headers\":null}"); + assertEquals(200, c.status()); + assertEquals(List.of(), c.headers()); + } + +} diff --git a/package.json b/package.json index 6a841023..2b8facab 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "author": "devfive", "devDependencies": { "eslint-plugin-devup": "^2.0.19", - "oxlint": "^1.66.0", + "oxlint": "^1.69.0", "husky": "^9.1", "bun-test-env-dom": "^1.0", "@devup-ui/bun-plugin": "^1.0", @@ -23,7 +23,7 @@ "prelint:fix": "cargo clippy --fix --all-targets --all-features --allow-dirty && cargo clippy --fix --workspace --no-default-features --allow-dirty && cargo fmt", "lint:publish": "cargo publish --dry-run -p vespera_core && cargo publish --dry-run -p vespera_macro && cargo publish --dry-run -p vespera_inprocess && cargo publish --dry-run -p vespera_jni && cargo publish --dry-run -p vespera", "test": "bun test", - "posttest": "cargo tarpaulin --out xml --out stdout --out html --all-targets", + "posttest": "cargo test --workspace --doc && cargo tarpaulin --out xml --out stdout --out html --all-targets", "dev": "bun run --workspaces dev", "api": "cargo run", "prepare": "husky",