From e1fecf81f5c3f24aea178cb43e46c48d931e9866 Mon Sep 17 00:00:00 2001 From: James Lal Date: Tue, 28 Jul 2026 23:16:55 -0600 Subject: [PATCH 1/3] feat: add automated client sync action --- .beads/issues.jsonl | 2 + .github/workflows/ci.yml | 8 + .github/workflows/publish.yml | 52 +- CHANGELOG.md | 14 + README.md | 98 ++- action.yml | 100 +++ scripts/action-install.sh | 53 ++ scripts/action-report.mjs | 45 ++ scripts/action-run.sh | 17 + scripts/package-release.sh | 16 + scripts/verify-download.mjs | 13 + scripts/write-checksum.mjs | 11 + src/bin/openapi-to-rust.rs | 111 +++ src/client_sync.rs | 633 ++++++++++++++++++ src/lib.rs | 2 + tests/fixtures/client-sync/client/Cargo.lock | 75 +++ tests/fixtures/client-sync/client/Cargo.toml | 10 + .../client/src/generated/REQUIRED_DEPS.toml | 7 + .../client-sync/client/src/generated/mod.rs | 15 + .../client-sync/client/src/generated/types.rs | 14 + tests/fixtures/client-sync/client/src/lib.rs | 1 + .../fixtures/client-sync/openapi-clients.yml | 11 + .../fixtures/client-sync/openapi-to-rust.toml | 7 + tests/fixtures/client-sync/openapi.yaml | 13 + 24 files changed, 1322 insertions(+), 6 deletions(-) create mode 100644 action.yml create mode 100644 scripts/action-install.sh create mode 100644 scripts/action-report.mjs create mode 100644 scripts/action-run.sh create mode 100644 scripts/package-release.sh create mode 100644 scripts/verify-download.mjs create mode 100644 scripts/write-checksum.mjs create mode 100644 src/client_sync.rs create mode 100644 tests/fixtures/client-sync/client/Cargo.lock create mode 100644 tests/fixtures/client-sync/client/Cargo.toml create mode 100644 tests/fixtures/client-sync/client/src/generated/REQUIRED_DEPS.toml create mode 100644 tests/fixtures/client-sync/client/src/generated/mod.rs create mode 100644 tests/fixtures/client-sync/client/src/generated/types.rs create mode 100644 tests/fixtures/client-sync/client/src/lib.rs create mode 100644 tests/fixtures/client-sync/openapi-clients.yml create mode 100644 tests/fixtures/client-sync/openapi-to-rust.toml create mode 100644 tests/fixtures/client-sync/openapi.yaml diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 841dd45..4bedc76 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,5 @@ {"id":"openapi-generator-dsu","title":"OpenAPI 3.1 type:[X,null] on required properties generates non-Option field","description":"For OpenAPI 3.1 schemas where a property is BOTH listed in 'required' AND declared nullable via the 3.1 type-array form (type: [\"string\",\"null\"]), the generator emits a non-Option field. Deserialization then fails against real responses that send null.\n\nVERIFIED AGAINST THE LIVE RUNPOD v2 API. Two operations fail outright today:\n- GET /v2/catalog/gpus -\u003e GpuType.pool is null in production; spec says type: [\"string\",\"null\"], required. Generated: 'pub pool: String'.\n- GET /v2/pods -\u003e Pod.template is null in production. Generated: 'pub template: String'.\n\nConfirmed affected fields in https://api.runpod.io/v2/openapi.json (4, walking allOf branches):\n GpuType.pool, Pod.dataCenterId, Pod.template, Pod.startedAt\n\nPod.startedAt is the worst latent case: any pod that has not started returns null, which takes out the entire listPods call for that account.\n\nNote Pod composes via allOf, so the required list and properties live in an allOf branch — a fix must traverse composition, not just top-level component properties.\n\nRoot cause reported by live-testing agent (unverified by me): nullability is computed as is_nullable() || is_nullable_pattern() in src/analysis.rs (~1936-1937, ~2506-2514), covering only 3.0 'nullable: true' and the anyOf-with-null shape. Schema::type_array_contains_null() (src/openapi.rs ~649-656) is called from only one site (analysis.rs ~1555). generator.rs (~2497-2517) then yields a plain String.\n\nClosed issue openapi-generator-bgo fixed only the anyOf case; this type-array case is uncovered.\n\nSeverity: 3.1 is the modern dialect and this silently produces a client that cannot read production responses. This is first-run breakage of the worst kind — it compiles, then fails at runtime.","acceptance_criteria":"A required property declared type: [\"string\",\"null\"] generates Option\u003cString\u003e, including when declared inside an allOf branch. Regression test covers both the direct component-schema case and the allOf-composed case. Generating the RunPod v2 spec yields Option for GpuType.pool, Pod.template, Pod.startedAt, and Pod.dataCenterId.","status":"closed","priority":0,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-26T23:51:03Z","created_by":"James Lal","updated_at":"2026-07-27T00:59:39Z","closed_at":"2026-07-27T00:59:39Z","close_reason":"Fixed in fad3ed3; verified against the live RunPod v2 API (list_gpu_types and list_pods now deserialize, with real nulls present in the responses). 362 tests pass.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-x8q","title":"Add manifest-driven client sync and GitHub Action","description":"Add a first-class multi-client lifecycle to openapi-to-rust. A checked-in manifest declares local or remote OpenAPI specs, generator configs, vendored spec paths, and Cargo packages. The CLI supports read-only check, local update, and remote sync; generated clients are compiled and reported. Package the same behavior as a thin GitHub Action/reusable scheduled workflow that can create or update PRs. Publish prebuilt CLI binaries so Actions do not cargo-install the generator.","design":"Keep deterministic lifecycle logic in the existing openapi-to-rust CLI. The Action only installs the matching prebuilt binary and maps GitHub inputs/outputs; GitHub-specific PR orchestration belongs in a reusable workflow. Preserve per-release version alignment between CLI and Action.","acceptance_criteria":"1. A versioned YAML manifest supports checked-in and remote specs. 2. CLI check is read-only and detects stale or uncompilable clients. 3. CLI update regenerates checked-in specs. 4. CLI sync validates and vendors remote specs before generation. 5. Tests cover manifest validation, local check/update, remote normalization/drift behavior, and failure reporting. 6. A root action.yml invokes the matching prebuilt CLI. 7. A reusable workflow demonstrates scheduled sync and PR creation. 8. Release automation publishes checksummed binaries for Linux, macOS, and Windows. 9. Documentation includes local and GitHub usage.","notes":"Implementation plan: (1) add src/client_sync.rs with versioned YAML manifest, local/remote spec handling, normalization, check/update/sync orchestration, Cargo check/test, and structured reports; (2) wire subcommands; (3) add root composite action.yml and reusable scheduled PR workflow; (4) extend tag publishing with checksummed native archives; (5) document and test. First version expects an existing generator TOML and Cargo package per client; crate scaffolding/publishing remain consumer policy.","status":"in_progress","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-29T04:57:25Z","created_by":"James Lal","updated_at":"2026-07-29T04:58:37Z","started_at":"2026-07-29T04:57:28Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-1fz","title":"Make scratch Cargo directories portable across CI runners","description":"PR #46 clean Linux runner cannot create hard-coded /private/tmp Cargo target/build directories in generated-crate integration tests. Derive all scratch build paths from each TempDir and verify every affected test plus full CI.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-29T00:10:21Z","created_by":"James Lal","updated_at":"2026-07-29T00:17:39Z","started_at":"2026-07-29T00:10:24Z","closed_at":"2026-07-29T00:17:39Z","close_reason":"Replaced all hard-coded /private/tmp Cargo build and target paths with per-test TempDir paths; focused fresh-build tests, full all-features suite, fmt, and clippy pass.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-6vy","title":"Fix clean-runner scratch-crate dependency resolution","description":"PR #46 CI fails because newly added generated-crate integration tests invoke Cargo with --offline before emitted dependencies such as async-trait exist in a clean runner cache. Remove unconditional offline resolution from the new scratch tests and verify with a cold Cargo cache.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-28T23:59:37Z","created_by":"James Lal","updated_at":"2026-07-29T00:05:51Z","started_at":"2026-07-28T23:59:42Z","closed_at":"2026-07-29T00:05:51Z","close_reason":"Removed unconditional offline mode from all new scratch-crate Cargo invocations; cold-cache focused suite and full all-features CI tests pass.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-60d","title":"Compile-check all 55 supported corpus specs","description":"Run scripts/spec-compile.sh across the complete supported OpenAPI corpus after the Storyden generator fixes, with generation and isolated cargo check for every supported spec.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-28T22:51:36Z","created_by":"James Lal","updated_at":"2026-07-28T23:15:21Z","started_at":"2026-07-28T22:51:41Z","closed_at":"2026-07-28T23:15:21Z","close_reason":"All 55 supported OpenAPI corpus documents generated and compiled successfully with isolated manifests; Microsoft Graph was force-compiled despite the standard resource gate. Gitea remains the expected Swagger 2.0 exclusion.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -53,6 +54,7 @@ {"id":"openapi-generator-cv4","title":"Support exploded query parameters (GH #27)","description":"GH issue 27: query params with style=form, explode=true (OAS defaults) and object schemas currently map to Option\u003cimpl AsRef\u003cstr\u003e\u003e and are sent as one opaque string. Per OAS/RFC6570 form-explode, each object property must become its own query pair (?color=red). Fix: analyzer synthesizes/resolves a typed struct for object query params with form+explode semantics; client generator emits req.query(\u0026struct) so reqwest/serde_urlencoded serializes properties as individual pairs.","notes":"Implemented on branch issue-27-exploded-query-params, draft PR https://github.com/gpu-cli/openapi-to-rust/pull/28. Close when PR merges. Follow-ups: openapi-generator-anu (deepObject/explode=false/arrays), openapi-generator-0jz (server side).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T23:22:24Z","created_by":"James Lal","updated_at":"2026-07-13T04:21:19Z","started_at":"2026-07-12T23:22:49Z","closed_at":"2026-07-13T04:21:19Z","close_reason":"Released in v0.6.0 (PR #28)","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-5q8","title":"DateStrategy::Time emits broken serde codec for time::Date/time::Time (GH #25)","description":"GitHub issue gpu-cli/openapi-to-rust#25: fields with format: date/time under DateStrategy::Time get #[serde(with = \"time::serde::iso8601\")], but that module only supports OffsetDateTime — generated code fails to compile. Fix: emit time::serde::format_description! helper codec modules (time_date_format / time_time_format) into generated code, and correct the time dep requirement features (serde alone doesn't even enable rfc3339).","notes":"Fixed in PR https://github.com/gpu-cli/openapi-to-rust/pull/26 (branch worktree-issue-25-time-date-serde). Close when PR merges.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-11T18:36:28Z","created_by":"James Lal","updated_at":"2026-07-11T18:57:02Z","started_at":"2026-07-11T18:36:45Z","closed_at":"2026-07-11T18:57:02Z","close_reason":"Fixed in PR #26, merged to main, released in v0.5.3","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-dpd","title":"Hybrid string-or-object discriminated unions deserialize-fail","description":"When an anyOf/oneOf union contains a discriminator AND a non-object branch (e.g. string-enum like ToolChoiceOptions), the generator emits a tagged enum that cannot deserialize the string form. Real-world hit: OpenAI ToolChoiceParam returns 'auto' in Response.tool_choice, but generated type is #[serde(tag=\"type\")] enum with no untagged String variant. Need to fall back to #[serde(untagged)] when the union mixes string/scalar branches with tagged-object branches, OR add a String fallback variant before the tagged variants.","notes":"Live repro: `ToolChoiceParam` from openai.yaml line 52518. anyOf has 8 branches; the first (`ToolChoiceOptions`) is a string-enum (\"none\"|\"auto\"|\"required\"), the rest are objects with discriminator propertyName=type. Generator emits `#[serde(tag=\"type\")] enum ToolChoiceParam { ToolChoiceOptions(ToolChoiceOptions), ... }` which cannot deserialize the string \"auto\" because serde tries to read a \"type\" field from a JSON string. Fix: when an anyOf/oneOf branch is a non-object schema (string/number/etc), the generator must emit `#[serde(untagged)]` with the scalar branch first OR add a String variant before the tagged variants. Hit on real OpenAI Responses API `Response.tool_choice` field.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-10T23:47:35Z","created_by":"James Lal","updated_at":"2026-05-11T00:12:46Z","started_at":"2026-05-10T23:54:10Z","closed_at":"2026-05-11T00:12:46Z","close_reason":"Fixed in src/analysis.rs: (dpd) analyze_oneof_union now downgrades to untagged when any branch is non-object — verified live against OpenAI Response.tool_choice='auto' which now deserializes as ToolChoiceParam::ToolChoiceOptions(Auto). (bgo) merge_schema_into_properties now ORs in is_nullable_pattern() for allOf-merged props — verified live against OpenAI Response.incomplete_details which is now Option\u003cResponseIncompleteDetails\u003e and deserializes null cleanly. All 4 smoke tests (OpenAI+Anthropic, sync+stream) pass.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-onb","title":"Adopt client sync manifest in rust-api-clients","description":"Replace the duplicated scheduled orchestration in the sibling rust-api-clients repository with an openapi-clients.yml manifest and the released gpu-cli/openapi-to-rust Action. Preserve provider overlays, dependency reconciliation, semver classification, smoke tests, and publishing policy that remain repository-specific.","acceptance_criteria":"1. Every current provider/module is represented in openapi-clients.yml. 2. Pull requests run clients check. 3. The scheduled workflow runs clients sync with pull-request creation. 4. Existing normalized drift behavior and compile/test gates remain covered. 5. Any xtask logic made redundant by the generator is removed.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-29T05:16:06Z","created_by":"James Lal","updated_at":"2026-07-29T05:16:06Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-d7h","title":"Align wide comparison blocks with narrative column","description":"Comparison headings and prose now align to the standard reading column, but wide tables and other structured blocks still begin at the outer wide-shell edge. Give every article block a shared left edge while preserving extra width to the right for tables and comparison grids.","acceptance_criteria":"On both comparison pages, tables and other structured blocks share the same left edge as their headings and prose; wide blocks continue using available space to the right; mobile overflow remains accessible; build, HTML, SEO, and layout checks pass.","notes":"Applied one responsive --wide-content-inset to every direct child of .prose-wide. Narrative children remain capped at 72ch, while tables, decision grids, command comparisons, code blocks, and pagination use the remaining width to the right. Verified both tables align with their headings in a 2000x3000 local Chrome render. npm run build, audit:html, audit:seo, and the post-change layout detector all pass with zero findings.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-29T03:55:54Z","created_by":"James Lal","updated_at":"2026-07-29T03:57:39Z","started_at":"2026-07-29T03:55:59Z","closed_at":"2026-07-29T03:57:39Z","close_reason":"All comparison-page article blocks now share one left edge while structured content retains its wider rightward span; browser and automated checks pass.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-bkv","title":"Align comparison prose with page heading","description":"The wide comparison layout introduced a mismatched left edge: the hero remains on the standard content column while section headings, callouts, and body prose begin at the expanded table edge. Align ordinary prose content with the hero while preserving wide tables and responsive overflow.","acceptance_criteria":"On both comparison pages, hero copy, section headings, callouts, and body prose share a coherent left edge; comparison tables and other wide data structures still use the expanded track; mobile remains readable; build and site audits pass.","notes":"Fixed the wide comparison layout by adding a shared wide-shell token and applying a responsive narrative inset to direct prose children. The inset is half the difference between the 1180px standard shell and the active wide shell, capped at 130px and collapsing to zero on narrow viewports. Tables, decision grids, command comparisons, and pagination remain on the wide article track. Verified with local Chrome screenshots at 2000px, layout detector (0 findings), npm run build, npm run audit:html, and npm run audit:seo.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-29T03:44:46Z","created_by":"James Lal","updated_at":"2026-07-29T03:48:26Z","started_at":"2026-07-29T03:44:52Z","closed_at":"2026-07-29T03:48:26Z","close_reason":"Hero, callout, section headings, and body prose now share one left edge while wide comparison content retains the expanded track; browser and automated checks pass.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-qav","title":"Let comparison tables use the available desktop width","description":"Fix comparison-page layout so wide tables use the available content column instead of being constrained to the 72ch prose measure, while preserving readable paragraph line lengths, the desktop TOC, and mobile horizontal scrolling.","acceptance_criteria":"Both comparison tables fit their desktop content area without premature horizontal clipping; prose remains capped at the reading measure; mobile tables remain accessible and scrollable; Astro, HTML, and SEO quality gates pass.","notes":"Implemented opt-in wide documentation layout for comparison pages. Article track now fills up to a 1440px shell while direct prose remains capped at the reading width; desktop TOC collapses at 1280px for comparison pages; tables retain accessible horizontal scrolling and narrower mobile sticky columns. Verified with layout detector (0 findings), npm run build, npm run audit:html, and npm run audit:seo.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-29T02:29:03Z","created_by":"James Lal","updated_at":"2026-07-29T02:32:31Z","started_at":"2026-07-29T02:29:10Z","closed_at":"2026-07-29T02:32:31Z","close_reason":"Comparison tables now use available desktop width while prose readability and mobile overflow behavior are preserved; all quality gates pass.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73c0f2a..06a07ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,6 +123,14 @@ jobs: # Keep cargo-install artifacts inside the target tree restored by # rust-cache; the script still isolates and removes its package root. CARGO_TARGET_DIR: ${{ github.workspace }}/target/install-smoke + - name: Build Action binary override + run: cargo build --locked --bin openapi-to-rust + - name: Smoke-test the local GitHub Action + uses: ./ + with: + binary: ${{ github.workspace }}/target/debug/openapi-to-rust + command: check + manifest: tests/fixtures/client-sync/openapi-clients.yml msrv: runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 08b8a6d..626f5bd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -30,8 +30,47 @@ jobs: RUSTDOCFLAGS: -D warnings - run: scripts/install-smoke.sh - publish: + binaries: needs: verify + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + executable: openapi-to-rust + - os: macos-15-intel + target: x86_64-apple-darwin + executable: openapi-to-rust + - os: macos-15 + target: aarch64-apple-darwin + executable: openapi-to-rust + - os: windows-latest + target: x86_64-pc-windows-msvc + executable: openapi-to-rust.exe + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - name: Build release binary + shell: bash + run: cargo build --locked --release --target "${{ matrix.target }}" --bin openapi-to-rust + - name: Package release binary + shell: bash + env: + TARGET: ${{ matrix.target }} + EXECUTABLE: ${{ matrix.executable }} + run: bash scripts/package-release.sh + - uses: actions/upload-artifact@v4 + with: + name: binary-${{ matrix.target }} + path: dist/* + if-no-files-found: error + + publish: + needs: [verify, binaries] runs-on: ubuntu-latest environment: crates-io permissions: @@ -41,6 +80,11 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 + - uses: actions/download-artifact@v4 + with: + pattern: binary-* + path: release-assets + merge-multiple: true - name: Authenticate with crates.io id: crates-io-auth uses: rust-lang/crates-io-auth-action@v1 @@ -52,6 +96,6 @@ jobs: GH_TOKEN: ${{ github.token }} run: | tag="${GITHUB_REF#refs/tags/}" - gh release create "$tag" \ - --title "$tag" \ - --generate-notes + gh release view "$tag" >/dev/null 2>&1 \ + || gh release create "$tag" --title "$tag" --generate-notes + gh release upload "$tag" release-assets/* --clobber diff --git a/CHANGELOG.md b/CHANGELOG.md index d8e9754..0d31b82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes are recorded here. This project follows semantic versioning, with one pre-1.0 qualification: a minor release may change generated Rust APIs when correcting output that was wrong or incomplete on the wire. +## [Unreleased] + +### Added + +- `openapi-to-rust clients check|update|sync` manages a versioned YAML manifest + of checked-in and remote API clients, validates and vendors remote spec + changes, verifies generated output, and compiles the configured Cargo target. +- The repository is directly usable as a GitHub Action. Scheduled remote syncs + can create or update a stable pull request, using a draft when generation or + compilation fails so upstream drift remains visible. +- Tagged releases publish checksummed native CLI archives for Linux x86-64, + macOS x86-64/Apple Silicon, and Windows x86-64. The Action installs the + matching prebuilt instead of compiling the generator. + ## [0.11.0] - 2026-07-27 Nearly everything here was found by generating a client from RunPod's published diff --git a/README.md b/README.md index 0a6ecbd..fa8f549 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,9 @@ cargo install --locked openapi-to-rust ``` This compiles the CLI locally and requires a Rust toolchain. Prebuilt binaries -are not currently published. If you use the generator as a Rust library instead, -run `cargo add openapi-to-rust`. +for Linux x86-64, macOS x86-64/Apple Silicon, and Windows x86-64 are attached +to each GitHub Release with SHA-256 checksums. If you use the generator as a +Rust library instead, run `cargo add openapi-to-rust`. ## Try it in one command @@ -146,6 +147,94 @@ Then: openapi-to-rust generate --config openapi-to-rust.toml ``` +### Keep a collection of clients current + +`openapi-clients.yml` declares checked-in and remotely sourced clients. Each +entry uses an ordinary generator TOML and names the Cargo package that must +continue to compile. The TOML's `spec_path` must match the local `path` or +remote `vendor` path in this manifest. + +```yaml +version: 1 +clients: + - name: internal + spec: + path: openapi/internal.yaml + config: clients/internal/openapi-to-rust.toml + cargo: + manifest_path: Cargo.toml + package: internal-api + test: true + + - name: runpod-v2 + spec: + url: https://api.runpod.io/v2/openapi.json + vendor: specs/runpod/v2.json + normalize: + strip: + - /info/x-build-time + config: crates/runpod-api/gen/v2.toml + cargo: + manifest_path: Cargo.toml + package: runpod-api +``` + +Use `check` in pull-request CI. It performs no source or generated-file writes +and tells contributors to run `update` when output is stale. `update` uses only +checked-in local or vendored documents. `sync` is the networked operation: it +validates remote documents, ignores configured volatile fields when comparing +them, vendors meaningful changes, regenerates, and runs Cargo checks. + +```bash +openapi-to-rust clients check +openapi-to-rust clients update +openapi-to-rust clients sync +openapi-to-rust clients sync --client runpod-v2 +``` + +For an internal checked-in API, keep the Action read-only: + +```yaml +permissions: + contents: read + +steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: gpu-cli/openapi-to-rust@v0.12.0 + with: + command: check + manifest: openapi-clients.yml +``` + +For remote APIs, schedule `sync` and let the Action maintain one stable pull +request. Successful changes produce a normal PR; generation or compilation +failures produce a draft PR containing the upstream spec change for diagnosis. + +```yaml +name: Sync OpenAPI clients +on: + schedule: + - cron: "17 6 * * *" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: gpu-cli/openapi-to-rust@v0.12.0 + with: + command: sync + manifest: openapi-clients.yml + pull-request: true +``` + ### Select only the client operations you use `[client]` is optional. Without it—or with an empty `operations` list—the HTTP client keeps every operation for backward compatibility. Selectors share the server grammar: exact `operationId`, exact `METHOD /path`, or `tag:`. @@ -569,6 +658,11 @@ openapi-to-rust generate --config openapi-to-rust.toml openapi-to-rust generate --config openapi-to-rust.toml --types-conservative openapi-to-rust validate --config openapi-to-rust.toml +# Multi-client lifecycle. +openapi-to-rust clients check +openapi-to-rust clients update +openapi-to-rust clients sync --client runpod-v2 + # Server scope management — preserves TOML formatting (toml_edit) openapi-to-rust server list # all operations openapi-to-rust server list --tag Responses # filter by tag diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..23df470 --- /dev/null +++ b/action.yml @@ -0,0 +1,100 @@ +name: OpenAPI to Rust client sync +description: Generate, verify, compile, and optionally update pull requests for Rust API clients. + +inputs: + command: + description: check, update, or sync + required: false + default: check + manifest: + description: Path to the versioned client manifest + required: false + default: openapi-clients.yml + client: + description: Optional client name to run + required: false + default: "" + version: + description: Generator version; inferred from a version-tagged Action reference when omitted + required: false + default: "" + binary: + description: Existing openapi-to-rust binary to use instead of downloading a release + required: false + default: "" + pull-request: + description: Create or update a pull request when sync changes files + required: false + default: "false" + pull-request-branch: + description: Stable branch used for automated sync pull requests + required: false + default: openapi-sync/clients + pull-request-title: + description: Pull request title + required: false + default: "chore: sync generated OpenAPI clients" + pull-request-labels: + description: Newline-separated pull request labels + required: false + default: openapi-sync + +outputs: + changed: + description: Number of clients whose remote specification or generated output changed + value: ${{ steps.run.outputs.changed }} + failed: + description: Number of clients that failed generation or compilation + value: ${{ steps.run.outputs.failed }} + report: + description: Path to the JSON report + value: ${{ steps.run.outputs.report }} + pull-request-url: + description: Created or updated pull request URL + value: ${{ steps.pr.outputs.pull-request-url }} + +runs: + using: composite + steps: + - name: Install openapi-to-rust + id: install + shell: bash + env: + INPUT_BINARY: ${{ inputs.binary }} + INPUT_VERSION: ${{ inputs.version }} + ACTION_REF: ${{ github.action_ref }} + ACTION_REPOSITORY: ${{ github.action_repository }} + run: bash "$GITHUB_ACTION_PATH/scripts/action-install.sh" + + - name: Generate and verify clients + id: run + shell: bash + env: + OPENAPI_TO_RUST_BIN: ${{ steps.install.outputs.binary }} + CLIENT_COMMAND: ${{ inputs.command }} + CLIENT_MANIFEST: ${{ inputs.manifest }} + CLIENT_NAME: ${{ inputs.client }} + run: bash "$GITHUB_ACTION_PATH/scripts/action-run.sh" + + - name: Create or update sync pull request + id: pr + if: ${{ inputs.pull-request == 'true' && steps.run.outputs.changed != '0' }} + uses: peter-evans/create-pull-request@v8 + with: + branch: ${{ inputs.pull-request-branch }} + title: ${{ inputs.pull-request-title }} + commit-message: ${{ inputs.pull-request-title }} + labels: ${{ inputs.pull-request-labels }} + draft: ${{ steps.run.outputs.exit_code != '0' }} + body: | + Automated OpenAPI client synchronization. + + The workflow generated each changed client and ran its configured Cargo checks. + A draft pull request indicates that generation or compilation needs attention. + + - name: Fail when a client did not verify + if: ${{ steps.run.outputs.exit_code != '0' }} + shell: bash + env: + EXIT_CODE: ${{ steps.run.outputs.exit_code }} + run: exit "$EXIT_CODE" diff --git a/scripts/action-install.sh b/scripts/action-install.sh new file mode 100644 index 0000000..4791c9c --- /dev/null +++ b/scripts/action-install.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ -n "${INPUT_BINARY:-}" ]; then + if [ ! -x "$INPUT_BINARY" ]; then + echo "::error::binary is not executable: $INPUT_BINARY" + exit 1 + fi + echo "binary=$INPUT_BINARY" >>"$GITHUB_OUTPUT" + exit 0 +fi + +version="${INPUT_VERSION:-}" +if [ -z "$version" ]; then + case "${ACTION_REF:-}" in + v[0-9]*) version="${ACTION_REF#v}" ;; + *) + echo "::error::version is required when the Action is not referenced by a version tag" + exit 1 + ;; + esac +fi +version="${version#v}" + +runner_arch="$(uname -m)" +case "${RUNNER_OS:-}-${runner_arch}" in + Linux-x86_64) target="x86_64-unknown-linux-gnu" ;; + macOS-x86_64) target="x86_64-apple-darwin" ;; + macOS-arm64) target="aarch64-apple-darwin" ;; + Windows-x86_64) target="x86_64-pc-windows-msvc" ;; + *) + echo "::error::unsupported runner: ${RUNNER_OS:-unknown} ${runner_arch}" + exit 1 + ;; +esac + +archive="openapi-to-rust-v${version}-${target}.tar.gz" +repository="${ACTION_REPOSITORY:-gpu-cli/openapi-to-rust}" +base_url="https://github.com/${repository}/releases/download/v${version}" +install_root="${RUNNER_TEMP}/openapi-to-rust-${version}" +mkdir -p "$install_root/bin" +curl --fail --location --silent --show-error "$base_url/$archive" --output "$install_root/$archive" +curl --fail --location --silent --show-error "$base_url/$archive.sha256" --output "$install_root/$archive.sha256" +node "$GITHUB_ACTION_PATH/scripts/verify-download.mjs" "$install_root/$archive" "$install_root/$archive.sha256" +tar -xzf "$install_root/$archive" -C "$install_root/bin" + +binary="$install_root/bin/openapi-to-rust" +if [ "${RUNNER_OS:-}" = "Windows" ]; then + binary="$binary.exe" +fi +chmod +x "$binary" +echo "$install_root/bin" >>"$GITHUB_PATH" +echo "binary=$binary" >>"$GITHUB_OUTPUT" diff --git a/scripts/action-report.mjs b/scripts/action-report.mjs new file mode 100644 index 0000000..3adc230 --- /dev/null +++ b/scripts/action-report.mjs @@ -0,0 +1,45 @@ +import { appendFile, readFile } from "node:fs/promises"; + +const [reportPath, exitCode = "1"] = process.argv.slice(2); +let report; +let parseError; +try { + report = JSON.parse(await readFile(reportPath, "utf8")); +} catch (error) { + parseError = String(error); + report = { clients: [] }; +} + +const clients = Array.isArray(report.clients) ? report.clients : []; +const changed = clients.filter((client) => client.spec_changed || client.output_changed).length; +const failed = clients.filter((client) => client.error).length + (parseError ? 1 : 0); +await appendFile( + process.env.GITHUB_OUTPUT, + `changed=${changed}\nfailed=${failed}\nreport=${reportPath}\nexit_code=${exitCode}\n`, +); + +if (process.env.GITHUB_STEP_SUMMARY) { + const rows = clients.map((client) => { + const status = client.error ? "Failed" : "Passed"; + const drift = client.spec_changed ? "Updated" : "Unchanged"; + const detail = String(client.error ?? "Generated and compiled") + .replaceAll("|", "\\|") + .replaceAll("\n", "
"); + return `| ${client.name} | ${status} | ${drift} | ${detail} |`; + }); + if (parseError) { + const detail = parseError.replaceAll("|", "\\|").replaceAll("\n", "
"); + rows.push(`| workflow | Failed | Unchanged | Could not read the client report: ${detail} |`); + } + await appendFile( + process.env.GITHUB_STEP_SUMMARY, + [ + "## OpenAPI client sync", + "", + "| Client | Status | Remote spec | Detail |", + "|---|---|---|---|", + ...rows, + "", + ].join("\n"), + ); +} diff --git a/scripts/action-run.sh b/scripts/action-run.sh new file mode 100644 index 0000000..78919cd --- /dev/null +++ b/scripts/action-run.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -uo pipefail + +report="${RUNNER_TEMP:-/tmp}/openapi-to-rust-clients.json" +args=(clients "$CLIENT_COMMAND" --manifest "$CLIENT_MANIFEST" --json) +if [ -n "${CLIENT_NAME:-}" ]; then + args+=(--client "$CLIENT_NAME") +fi + +set +e +"$OPENAPI_TO_RUST_BIN" "${args[@]}" >"$report" +exit_code=$? +set -e + +cat "$report" +node "$GITHUB_ACTION_PATH/scripts/action-report.mjs" "$report" "$exit_code" +exit 0 diff --git a/scripts/package-release.sh b/scripts/package-release.sh new file mode 100644 index 0000000..cb5db5d --- /dev/null +++ b/scripts/package-release.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ -z "${TARGET:-}" ] || [ -z "${EXECUTABLE:-}" ]; then + echo "TARGET and EXECUTABLE are required" >&2 + exit 1 +fi + +version="$(awk -F'"' '/^version = / { print $2; exit }' Cargo.toml)" +archive="openapi-to-rust-v${version}-${TARGET}.tar.gz" +dist_dir="${DIST_DIR:-dist}" +package_dir="${PACKAGE_DIR:-target/package-release}" +mkdir -p "$dist_dir" "$package_dir" +cp -f "target/${TARGET}/release/${EXECUTABLE}" "$package_dir/${EXECUTABLE}" +tar -czf "$dist_dir/${archive}" -C "$package_dir" "${EXECUTABLE}" +node scripts/write-checksum.mjs "$dist_dir/${archive}" diff --git a/scripts/verify-download.mjs b/scripts/verify-download.mjs new file mode 100644 index 0000000..c439000 --- /dev/null +++ b/scripts/verify-download.mjs @@ -0,0 +1,13 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; + +const [archivePath, checksumPath] = process.argv.slice(2); +if (!archivePath || !checksumPath) { + throw new Error("usage: verify-download.mjs "); +} + +const expected = (await readFile(checksumPath, "utf8")).trim().split(/\s+/)[0]; +const actual = createHash("sha256").update(await readFile(archivePath)).digest("hex"); +if (actual !== expected) { + throw new Error(`checksum mismatch for ${archivePath}: expected ${expected}, got ${actual}`); +} diff --git a/scripts/write-checksum.mjs b/scripts/write-checksum.mjs new file mode 100644 index 0000000..ded4093 --- /dev/null +++ b/scripts/write-checksum.mjs @@ -0,0 +1,11 @@ +import { createHash } from "node:crypto"; +import { basename } from "node:path"; +import { readFile, writeFile } from "node:fs/promises"; + +const [archivePath] = process.argv.slice(2); +if (!archivePath) { + throw new Error("usage: write-checksum.mjs "); +} + +const checksum = createHash("sha256").update(await readFile(archivePath)).digest("hex"); +await writeFile(`${archivePath}.sha256`, `${checksum} ${basename(archivePath)}\n`); diff --git a/src/bin/openapi-to-rust.rs b/src/bin/openapi-to-rust.rs index 4df9bff..90194f8 100644 --- a/src/bin/openapi-to-rust.rs +++ b/src/bin/openapi-to-rust.rs @@ -95,6 +95,11 @@ enum Commands { #[arg(short, long, default_value = "openapi-to-rust.toml")] config: PathBuf, }, + /// Manage and verify a collection of generated API clients. + Clients { + #[command(subcommand)] + action: ClientCommands, + }, /// Server codegen commands (opt-in Axum scaffolding). Server { #[command(subcommand)] @@ -102,6 +107,43 @@ enum Commands { }, } +#[derive(Subcommand)] +enum ClientCommands { + /// Verify checked-in specs, generated output, and Cargo packages without writing. + Check { + #[arg(short, long, default_value = "openapi-clients.yml")] + manifest: PathBuf, + /// Limit the run to one named client. + #[arg(long)] + client: Option, + /// Emit a machine-readable JSON report. + #[arg(long)] + json: bool, + }, + /// Regenerate from checked-in local or vendored specs without network access. + Update { + #[arg(short, long, default_value = "openapi-clients.yml")] + manifest: PathBuf, + /// Limit the run to one named client. + #[arg(long)] + client: Option, + /// Emit a machine-readable JSON report. + #[arg(long)] + json: bool, + }, + /// Fetch remote specs, vendor changes, regenerate, and verify Cargo packages. + Sync { + #[arg(short, long, default_value = "openapi-clients.yml")] + manifest: PathBuf, + /// Limit the run to one named client. + #[arg(long)] + client: Option, + /// Emit a machine-readable JSON report. + #[arg(long)] + json: bool, + }, +} + #[derive(Subcommand)] enum ServerCommands { /// List every operation in a spec. Read-only. @@ -245,6 +287,38 @@ fn run(cli: Cli) -> Result<(), Box> { quiet, json, }), + Commands::Clients { action } => match action { + ClientCommands::Check { + manifest, + client, + json, + } => run_client_command( + manifest, + client, + openapi_to_rust::client_sync::ClientMode::Check, + json, + ), + ClientCommands::Update { + manifest, + client, + json, + } => run_client_command( + manifest, + client, + openapi_to_rust::client_sync::ClientMode::Update, + json, + ), + ClientCommands::Sync { + manifest, + client, + json, + } => run_client_command( + manifest, + client, + openapi_to_rust::client_sync::ClientMode::Sync, + json, + ), + }, Commands::Server { action } => match action { ServerCommands::List { spec, @@ -271,6 +345,43 @@ fn run(cli: Cli) -> Result<(), Box> { } } +fn run_client_command( + manifest: PathBuf, + client: Option, + mode: openapi_to_rust::client_sync::ClientMode, + json: bool, +) -> Result<(), Box> { + let report = openapi_to_rust::client_sync::run_clients(&manifest, mode, client.as_deref())?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + for client in &report.clients { + if let Some(error) = &client.error { + eprintln!("FAILED {:24} {error}", client.name); + } else { + let drift = match (client.spec_changed, client.output_changed) { + (true, true) => " (remote spec and generated output updated)", + (true, false) => " (remote spec updated; generated output unchanged)", + (false, true) => " (generated output updated)", + (false, false) => "", + }; + println!("OK {:24} generated and compiled{drift}", client.name); + } + } + println!( + "{} client(s), {} changed, {} failed", + report.clients.len(), + report.changed(), + report.failed() + ); + } + if report.failed() == 0 { + Ok(()) + } else { + Err(format!("{} client(s) failed", report.failed()).into()) + } +} + struct GenerateArgs { source: Option, config: Option, diff --git a/src/client_sync.rs b/src/client_sync.rs new file mode 100644 index 0000000..1e87cbf --- /dev/null +++ b/src/client_sync.rs @@ -0,0 +1,633 @@ +//! Manifest-driven generation and verification for collections of API clients. +//! +//! The same implementation backs local `openapi-to-rust clients` commands and +//! the GitHub Action. GitHub-specific concerns such as branches and pull +//! requests intentionally stay outside this module. + +use crate::cli::load_spec; +use crate::spec_source::{parse_spec, sanitize_source_provenance, validate_oas_document}; +use crate::{CodeGenerator, ConfigFile, SchemaAnalyzer, TypeMapper}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub const MANIFEST_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ClientMode { + Check, + Update, + Sync, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ClientManifest { + pub version: u32, + pub clients: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ClientEntry { + pub name: String, + pub spec: SpecSource, + pub config: PathBuf, + pub cargo: CargoCheck, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum SpecSource { + Local(LocalSpec), + Remote(RemoteSpec), +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LocalSpec { + pub path: PathBuf, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RemoteSpec { + pub url: String, + pub vendor: PathBuf, + #[serde(default)] + pub normalize: Normalize, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Normalize { + /// JSON pointers removed before comparing the fetched and vendored specs. + pub strip: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct CargoCheck { + pub manifest_path: PathBuf, + pub package: Option, + pub all_features: bool, + pub locked: bool, + pub test: bool, +} + +impl Default for CargoCheck { + fn default() -> Self { + Self { + manifest_path: PathBuf::from("Cargo.toml"), + package: None, + all_features: true, + locked: true, + test: false, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ClientRunReport { + pub mode: ClientMode, + pub manifest: String, + pub clients: Vec, +} + +impl ClientRunReport { + pub fn failed(&self) -> usize { + self.clients + .iter() + .filter(|client| client.error.is_some()) + .count() + } + + pub fn changed(&self) -> usize { + self.clients + .iter() + .filter(|client| client.spec_changed || client.output_changed) + .count() + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ClientResult { + pub name: String, + pub spec_changed: bool, + pub output_changed: bool, + pub generated: bool, + pub compiled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug)] +struct ResolvedEntry { + entry: ClientEntry, + config: PathBuf, + spec_path: PathBuf, + cargo_manifest: PathBuf, +} + +pub fn run_clients( + manifest_path: &Path, + mode: ClientMode, + selected: Option<&str>, +) -> Result> { + let manifest_path = manifest_path.canonicalize().map_err(|error| { + format!( + "failed to resolve client manifest '{}': {error}", + manifest_path.display() + ) + })?; + let root = manifest_path + .parent() + .ok_or_else(|| "client manifest has no parent directory".to_string())?; + let manifest = load_manifest(&manifest_path)?; + let entries = resolve_entries(manifest, root, selected)?; + let mut results = Vec::with_capacity(entries.len()); + + for resolved in entries { + let mut result = ClientResult { + name: resolved.entry.name.clone(), + spec_changed: false, + output_changed: false, + generated: false, + compiled: false, + error: None, + }; + if let Err(error) = run_one(&resolved, mode, &mut result) { + result.error = Some(error.to_string()); + } + results.push(result); + } + + Ok(ClientRunReport { + mode, + manifest: manifest_path.display().to_string(), + clients: results, + }) +} + +fn load_manifest(path: &Path) -> Result> { + let raw = std::fs::read_to_string(path)?; + let manifest: ClientManifest = serde_yaml::from_str(&raw) + .map_err(|error| format!("failed to parse '{}': {error}", path.display()))?; + if manifest.version != MANIFEST_VERSION { + return Err(format!( + "unsupported client manifest version {}; expected {}", + manifest.version, MANIFEST_VERSION + ) + .into()); + } + if manifest.clients.is_empty() { + return Err("client manifest must declare at least one client".into()); + } + let mut names = BTreeSet::new(); + for client in &manifest.clients { + if client.name.trim().is_empty() { + return Err("client names cannot be empty".into()); + } + if !names.insert(client.name.as_str()) { + return Err(format!("duplicate client name '{}'", client.name).into()); + } + if let SpecSource::Remote(remote) = &client.spec { + crate::spec_source::validate_remote_spec_url(&remote.url) + .map_err(|error| format!("client '{}': {error}", client.name))?; + for pointer in &remote.normalize.strip { + if !pointer.is_empty() && !pointer.starts_with('/') { + return Err(format!( + "client '{}': normalize.strip value '{pointer}' must be a JSON pointer", + client.name + ) + .into()); + } + } + } + } + Ok(manifest) +} + +fn resolve_entries( + manifest: ClientManifest, + root: &Path, + selected: Option<&str>, +) -> Result, Box> { + let mut found = false; + let mut entries = Vec::new(); + for entry in manifest.clients { + if selected.is_some_and(|name| name != entry.name) { + continue; + } + found = true; + let spec_path = match &entry.spec { + SpecSource::Local(local) => resolve_path(root, &local.path), + SpecSource::Remote(remote) => resolve_path(root, &remote.vendor), + }; + entries.push(ResolvedEntry { + config: resolve_path(root, &entry.config), + cargo_manifest: resolve_path(root, &entry.cargo.manifest_path), + spec_path, + entry, + }); + } + if !found { + return Err(format!( + "client '{}' is not declared in the manifest", + selected.unwrap_or_default() + ) + .into()); + } + Ok(entries) +} + +fn resolve_path(root: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + root.join(path) + } +} + +fn run_one( + resolved: &ResolvedEntry, + mode: ClientMode, + result: &mut ClientResult, +) -> Result<(), Box> { + if mode == ClientMode::Sync + && let SpecSource::Remote(remote) = &resolved.entry.spec + { + result.spec_changed = sync_remote_spec(remote, &resolved.spec_path)?; + } + + if !resolved.spec_path.exists() { + let hint = match resolved.entry.spec { + SpecSource::Remote(_) => "run `openapi-to-rust clients sync` first", + SpecSource::Local(_) => "check the manifest's spec.path", + }; + return Err(format!( + "specification '{}' does not exist; {hint}", + resolved.spec_path.display() + ) + .into()); + } + + result.output_changed = generate_from_config( + &resolved.config, + &resolved.spec_path, + mode == ClientMode::Check, + )?; + result.generated = true; + run_cargo(&resolved.entry.cargo, &resolved.cargo_manifest)?; + result.compiled = true; + Ok(()) +} + +fn sync_remote_spec( + remote: &RemoteSpec, + vendor_path: &Path, +) -> Result> { + let incoming = load_spec(&remote.url)?; + update_vendored_spec(&incoming, &remote.url, vendor_path, &remote.normalize.strip) +} + +fn update_vendored_spec( + incoming: &str, + incoming_source: &str, + vendor_path: &Path, + strip: &[String], +) -> Result> { + let incoming_normalized = normalize_spec(incoming, incoming_source, strip)?; + let existing_normalized = std::fs::read_to_string(vendor_path) + .ok() + .and_then(|existing| { + normalize_spec(&existing, &vendor_path.display().to_string(), strip).ok() + }); + if existing_normalized.as_deref() == Some(incoming_normalized.as_slice()) { + return Ok(false); + } + if let Some(parent) = vendor_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(vendor_path, incoming)?; + Ok(true) +} + +fn normalize_spec( + raw: &str, + source: &str, + strip: &[String], +) -> Result, Box> { + let mut value = parse_spec(raw, source)?; + validate_oas_document(&value)?; + for pointer in strip { + strip_pointer(&mut value, pointer); + } + Ok(serde_json::to_vec(&value)?) +} + +fn strip_pointer(value: &mut serde_json::Value, pointer: &str) { + let Some((parent, encoded_key)) = pointer.rsplit_once('/') else { + return; + }; + let key = encoded_key.replace("~1", "/").replace("~0", "~"); + let target = if parent.is_empty() { + Some(value) + } else { + value.pointer_mut(parent) + }; + match target { + Some(serde_json::Value::Object(map)) => { + map.remove(&key); + } + Some(serde_json::Value::Array(items)) => { + if let Ok(index) = key.parse::() + && index < items.len() + { + items.remove(index); + } + } + _ => {} + } +} + +fn generate_from_config( + config_path: &Path, + expected_spec: &Path, + check: bool, +) -> Result> { + let provenance = raw_config_spec_source(config_path)?; + let mut generator_config = ConfigFile::load(config_path)?.into_generator_config(); + let configured_spec = generator_config.spec_path.canonicalize()?; + let expected_spec = expected_spec.canonicalize()?; + if configured_spec != expected_spec { + return Err(format!( + "config '{}' uses spec '{}', but the client manifest declares '{}'", + config_path.display(), + configured_spec.display(), + expected_spec.display() + ) + .into()); + } + + let source = generator_config.spec_path.to_string_lossy().to_string(); + let raw = load_spec(&source)?; + let value = parse_spec(&raw, &source)?; + validate_oas_document(&value)?; + generator_config.apply_spec_server_default(&value); + let mapper = TypeMapper::new(generator_config.types.clone()); + let mut analyzer = if generator_config.schema_extensions.is_empty() { + SchemaAnalyzer::with_type_mapper(value, mapper)? + } else { + SchemaAnalyzer::new_with_extensions_and_type_mapper( + value, + &generator_config.schema_extensions, + mapper, + )? + }; + let mut analysis = analyzer.analyze()?; + let generator = CodeGenerator::new(generator_config) + .with_source_provenance(sanitize_source_provenance(&provenance)); + let generated = generator.generate_all(&mut analysis)?; + let artifacts = generator.output_artifacts(&generated); + let changed = artifacts_changed(generator.config().output_dir.as_path(), &artifacts)?; + if check { + check_artifacts(generator.config().output_dir.as_path(), &artifacts)?; + } else { + write_artifacts(generator.config().output_dir.as_path(), &artifacts)?; + } + Ok(changed) +} + +fn raw_config_spec_source(path: &Path) -> Result> { + let content = std::fs::read_to_string(path)?; + let value: toml::Value = toml::from_str(&content)?; + value + .get("generator") + .and_then(|generator| generator.get("spec_path")) + .and_then(toml::Value::as_str) + .map(str::to_string) + .ok_or_else(|| "configuration is missing generator.spec_path".into()) +} + +fn write_artifacts( + output_dir: &Path, + artifacts: &BTreeMap, +) -> Result<(), Box> { + for (relative, content) in artifacts { + let path = output_dir.join(relative); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, content)?; + } + Ok(()) +} + +fn check_artifacts( + output_dir: &Path, + artifacts: &BTreeMap, +) -> Result<(), Box> { + let mut stale = Vec::new(); + for (relative, expected) in artifacts { + match std::fs::read_to_string(output_dir.join(relative)) { + Ok(actual) if actual == *expected => {} + Ok(_) => stale.push(format!("changed: {}", relative.display())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + stale.push(format!("missing: {}", relative.display())); + } + Err(error) => return Err(error.into()), + } + } + if stale.is_empty() { + Ok(()) + } else { + Err(format!( + "generated output is stale:\n {}\nRun `openapi-to-rust clients update` and commit the result.", + stale.join("\n ") + ) + .into()) + } +} + +fn artifacts_changed( + output_dir: &Path, + artifacts: &BTreeMap, +) -> Result> { + for (relative, expected) in artifacts { + match std::fs::read_to_string(output_dir.join(relative)) { + Ok(actual) if actual == *expected => {} + Ok(_) => return Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true), + Err(error) => return Err(error.into()), + } + } + Ok(false) +} + +fn run_cargo(cargo: &CargoCheck, manifest_path: &Path) -> Result<(), Box> { + if !manifest_path.exists() { + return Err(format!( + "Cargo manifest '{}' does not exist", + manifest_path.display() + ) + .into()); + } + run_cargo_command("check", cargo, manifest_path)?; + if cargo.test { + run_cargo_command("test", cargo, manifest_path)?; + } + Ok(()) +} + +fn run_cargo_command( + subcommand: &str, + cargo: &CargoCheck, + manifest_path: &Path, +) -> Result<(), Box> { + let mut command = Command::new("cargo"); + command + .arg(subcommand) + .arg("--manifest-path") + .arg(manifest_path); + if let Some(package) = &cargo.package { + command.arg("--package").arg(package); + } + if cargo.all_features { + command.arg("--all-features"); + } + if cargo.locked { + command.arg("--locked"); + } + let status = command.status()?; + if status.success() { + Ok(()) + } else { + Err(format!( + "cargo {subcommand} failed for '{}' with {status}", + manifest_path.display() + ) + .into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manifest_rejects_duplicate_names() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("clients.yml"); + std::fs::write( + &path, + r#" +version: 1 +clients: + - name: sample + spec: { path: openapi.yaml } + config: openapi-to-rust.toml + cargo: {} + - name: sample + spec: { path: another.yaml } + config: another.toml + cargo: {} +"#, + ) + .unwrap(); + let error = load_manifest(&path).unwrap_err().to_string(); + assert!(error.contains("duplicate client name 'sample'")); + } + + #[test] + fn normalization_ignores_order_and_configured_fields() { + let left = + r#"{"openapi":"3.1.0","info":{"title":"A","version":"1","x-built":"one"},"paths":{}}"#; + let right = + r#"{"paths":{},"info":{"x-built":"two","version":"1","title":"A"},"openapi":"3.1.0"}"#; + let strip = vec!["/info/x-built".to_string()]; + assert_eq!( + normalize_spec(left, "left.json", &strip).unwrap(), + normalize_spec(right, "right.json", &strip).unwrap() + ); + } + + #[test] + fn check_detects_stale_generated_output_and_update_repairs_it() { + let dir = tempfile::tempdir().unwrap(); + let spec = dir.path().join("openapi.yaml"); + let config = dir.path().join("openapi-to-rust.toml"); + std::fs::write( + &spec, + "openapi: 3.1.0\ninfo: { title: Sample, version: '1' }\npaths: {}\ncomponents:\n schemas:\n Greeting:\n type: object\n properties:\n message: { type: string }\n", + ) + .unwrap(); + std::fs::write( + &config, + "[generator]\nspec_path = \"openapi.yaml\"\noutput_dir = \"src/generated\"\nmodule_name = \"sample\"\n\n[features]\nenable_async_client = false\n", + ) + .unwrap(); + + let error = generate_from_config(&config, &spec, true) + .unwrap_err() + .to_string(); + assert!(error.contains("generated output is stale")); + assert!(generate_from_config(&config, &spec, false).unwrap()); + assert!(!generate_from_config(&config, &spec, true).unwrap()); + } + + #[test] + fn vendored_remote_changes_only_when_normalized_content_changes() { + let dir = tempfile::tempdir().unwrap(); + let vendor = dir.path().join("openapi.json"); + let existing = + r#"{"openapi":"3.1.0","info":{"title":"A","version":"1","x-built":"one"},"paths":{}}"#; + let reordered = + r#"{"paths":{},"info":{"x-built":"two","version":"1","title":"A"},"openapi":"3.1.0"}"#; + let changed = r#"{"openapi":"3.1.0","info":{"title":"B","version":"1"},"paths":{}}"#; + let strip = vec!["/info/x-built".to_string()]; + std::fs::write(&vendor, existing).unwrap(); + + assert!(!update_vendored_spec(reordered, "remote", &vendor, &strip).unwrap()); + assert_eq!(std::fs::read_to_string(&vendor).unwrap(), existing); + assert!(update_vendored_spec(changed, "remote", &vendor, &strip).unwrap()); + assert_eq!(std::fs::read_to_string(&vendor).unwrap(), changed); + } + + #[test] + fn manifest_update_then_check_generates_and_compiles_a_client() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("crate/src")).unwrap(); + std::fs::write( + dir.path().join("openapi.yaml"), + "openapi: 3.1.0\ninfo: { title: Sample, version: '1' }\npaths: {}\n", + ) + .unwrap(); + std::fs::write( + dir.path().join("openapi-to-rust.toml"), + "[generator]\nspec_path = \"openapi.yaml\"\noutput_dir = \"crate/src/generated\"\nmodule_name = \"sample\"\n\n[features]\nenable_async_client = false\n", + ) + .unwrap(); + std::fs::write( + dir.path().join("crate/Cargo.toml"), + "[package]\nname = \"sample-client\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\nserde = { version = \"1\", features = [\"derive\"] }\n", + ) + .unwrap(); + std::fs::write(dir.path().join("crate/src/lib.rs"), "pub mod generated;\n").unwrap(); + let manifest_path = dir.path().join("openapi-clients.yml"); + std::fs::write( + &manifest_path, + "version: 1\nclients:\n - name: sample\n spec: { path: openapi.yaml }\n config: openapi-to-rust.toml\n cargo:\n manifest_path: crate/Cargo.toml\n package: sample-client\n all_features: false\n locked: false\n", + ) + .unwrap(); + + let updated = run_clients(&manifest_path, ClientMode::Update, None).unwrap(); + assert_eq!(updated.failed(), 0); + assert_eq!(updated.changed(), 1); + let checked = run_clients(&manifest_path, ClientMode::Check, None).unwrap(); + assert_eq!(checked.failed(), 0); + assert_eq!(checked.changed(), 0); + } +} diff --git a/src/lib.rs b/src/lib.rs index f81a42a..a2af0bb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -48,6 +48,8 @@ pub mod analysis; #[cfg(feature = "cli")] pub mod cli; pub mod client_generator; +#[cfg(feature = "cli")] +pub mod client_sync; pub mod config; pub mod error; pub mod extensions; diff --git a/tests/fixtures/client-sync/client/Cargo.lock b/tests/fixtures/client-sync/client/Cargo.lock new file mode 100644 index 0000000..b503f07 --- /dev/null +++ b/tests/fixtures/client-sync/client/Cargo.lock @@ -0,0 +1,75 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "client-sync-fixture" +version = "0.0.0" +dependencies = [ + "serde", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/tests/fixtures/client-sync/client/Cargo.toml b/tests/fixtures/client-sync/client/Cargo.toml new file mode 100644 index 0000000..bd567e4 --- /dev/null +++ b/tests/fixtures/client-sync/client/Cargo.toml @@ -0,0 +1,10 @@ +[workspace] + +[package] +name = "client-sync-fixture" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } diff --git a/tests/fixtures/client-sync/client/src/generated/REQUIRED_DEPS.toml b/tests/fixtures/client-sync/client/src/generated/REQUIRED_DEPS.toml new file mode 100644 index 0000000..50ec1f0 --- /dev/null +++ b/tests/fixtures/client-sync/client/src/generated/REQUIRED_DEPS.toml @@ -0,0 +1,7 @@ +# Generated by openapi-to-rust v0.11.0. Source OpenAPI document: openapi.yaml +# Complete direct dependencies for this generated output. +# Append this fragment to the consuming crate's Cargo.toml, or +# merge it with existing dependency and feature sections. + +[dependencies] +serde = { version = "1", features = ["derive"] } diff --git a/tests/fixtures/client-sync/client/src/generated/mod.rs b/tests/fixtures/client-sync/client/src/generated/mod.rs new file mode 100644 index 0000000..360abc4 --- /dev/null +++ b/tests/fixtures/client-sync/client/src/generated/mod.rs @@ -0,0 +1,15 @@ +//! Generated API modules +//! +//! This module exports all generated API types and clients. +//! Do not edit manually - regenerate using the appropriate script. +//! +//! Generated by openapi-to-rust v0.11.0. Source OpenAPI document: openapi.yaml + +//! Configured `module_name` = `fixture`. Mount this tree under your +//! preferred path, e.g. `pub mod fixture;` in your crate root. + +#![allow(unused_imports)] + +pub mod types; + +pub use types::*; diff --git a/tests/fixtures/client-sync/client/src/generated/types.rs b/tests/fixtures/client-sync/client/src/generated/types.rs new file mode 100644 index 0000000..b2680cf --- /dev/null +++ b/tests/fixtures/client-sync/client/src/generated/types.rs @@ -0,0 +1,14 @@ +//! Generated types from OpenAPI specification +//! +//! This file contains all the generated types for the API. +//! Do not edit manually - regenerate using the appropriate script. +//! Generated by openapi-to-rust v0.11.0. Source OpenAPI document: openapi.yaml +#![allow(clippy::large_enum_variant)] +#![allow(clippy::format_in_format_args)] +#![allow(clippy::let_unit_value)] +#![allow(unreachable_patterns)] +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Greeting { + pub message: String, +} diff --git a/tests/fixtures/client-sync/client/src/lib.rs b/tests/fixtures/client-sync/client/src/lib.rs new file mode 100644 index 0000000..743aa41 --- /dev/null +++ b/tests/fixtures/client-sync/client/src/lib.rs @@ -0,0 +1 @@ +pub mod generated; diff --git a/tests/fixtures/client-sync/openapi-clients.yml b/tests/fixtures/client-sync/openapi-clients.yml new file mode 100644 index 0000000..84ae4ed --- /dev/null +++ b/tests/fixtures/client-sync/openapi-clients.yml @@ -0,0 +1,11 @@ +version: 1 +clients: + - name: fixture + spec: + path: openapi.yaml + config: openapi-to-rust.toml + cargo: + manifest_path: client/Cargo.toml + package: client-sync-fixture + all_features: false + locked: true diff --git a/tests/fixtures/client-sync/openapi-to-rust.toml b/tests/fixtures/client-sync/openapi-to-rust.toml new file mode 100644 index 0000000..199567b --- /dev/null +++ b/tests/fixtures/client-sync/openapi-to-rust.toml @@ -0,0 +1,7 @@ +[generator] +spec_path = "openapi.yaml" +output_dir = "client/src/generated" +module_name = "fixture" + +[features] +enable_async_client = false diff --git a/tests/fixtures/client-sync/openapi.yaml b/tests/fixtures/client-sync/openapi.yaml new file mode 100644 index 0000000..6418130 --- /dev/null +++ b/tests/fixtures/client-sync/openapi.yaml @@ -0,0 +1,13 @@ +openapi: 3.1.0 +info: + title: Client sync fixture + version: "1.0.0" +paths: {} +components: + schemas: + Greeting: + type: object + required: [message] + properties: + message: + type: string From c6464097c3bff30d215751840f110510e9e8cceb Mon Sep 17 00:00:00 2001 From: James Lal Date: Tue, 28 Jul 2026 23:18:51 -0600 Subject: [PATCH 2/3] chore: sync beads issue state --- .beads/issues.jsonl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 4bedc76..7f7240e 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,5 @@ {"id":"openapi-generator-dsu","title":"OpenAPI 3.1 type:[X,null] on required properties generates non-Option field","description":"For OpenAPI 3.1 schemas where a property is BOTH listed in 'required' AND declared nullable via the 3.1 type-array form (type: [\"string\",\"null\"]), the generator emits a non-Option field. Deserialization then fails against real responses that send null.\n\nVERIFIED AGAINST THE LIVE RUNPOD v2 API. Two operations fail outright today:\n- GET /v2/catalog/gpus -\u003e GpuType.pool is null in production; spec says type: [\"string\",\"null\"], required. Generated: 'pub pool: String'.\n- GET /v2/pods -\u003e Pod.template is null in production. Generated: 'pub template: String'.\n\nConfirmed affected fields in https://api.runpod.io/v2/openapi.json (4, walking allOf branches):\n GpuType.pool, Pod.dataCenterId, Pod.template, Pod.startedAt\n\nPod.startedAt is the worst latent case: any pod that has not started returns null, which takes out the entire listPods call for that account.\n\nNote Pod composes via allOf, so the required list and properties live in an allOf branch — a fix must traverse composition, not just top-level component properties.\n\nRoot cause reported by live-testing agent (unverified by me): nullability is computed as is_nullable() || is_nullable_pattern() in src/analysis.rs (~1936-1937, ~2506-2514), covering only 3.0 'nullable: true' and the anyOf-with-null shape. Schema::type_array_contains_null() (src/openapi.rs ~649-656) is called from only one site (analysis.rs ~1555). generator.rs (~2497-2517) then yields a plain String.\n\nClosed issue openapi-generator-bgo fixed only the anyOf case; this type-array case is uncovered.\n\nSeverity: 3.1 is the modern dialect and this silently produces a client that cannot read production responses. This is first-run breakage of the worst kind — it compiles, then fails at runtime.","acceptance_criteria":"A required property declared type: [\"string\",\"null\"] generates Option\u003cString\u003e, including when declared inside an allOf branch. Regression test covers both the direct component-schema case and the allOf-composed case. Generating the RunPod v2 spec yields Option for GpuType.pool, Pod.template, Pod.startedAt, and Pod.dataCenterId.","status":"closed","priority":0,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-26T23:51:03Z","created_by":"James Lal","updated_at":"2026-07-27T00:59:39Z","closed_at":"2026-07-27T00:59:39Z","close_reason":"Fixed in fad3ed3; verified against the live RunPod v2 API (list_gpu_types and list_pods now deserialize, with real nulls present in the responses). 362 tests pass.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"openapi-generator-x8q","title":"Add manifest-driven client sync and GitHub Action","description":"Add a first-class multi-client lifecycle to openapi-to-rust. A checked-in manifest declares local or remote OpenAPI specs, generator configs, vendored spec paths, and Cargo packages. The CLI supports read-only check, local update, and remote sync; generated clients are compiled and reported. Package the same behavior as a thin GitHub Action/reusable scheduled workflow that can create or update PRs. Publish prebuilt CLI binaries so Actions do not cargo-install the generator.","design":"Keep deterministic lifecycle logic in the existing openapi-to-rust CLI. The Action only installs the matching prebuilt binary and maps GitHub inputs/outputs; GitHub-specific PR orchestration belongs in a reusable workflow. Preserve per-release version alignment between CLI and Action.","acceptance_criteria":"1. A versioned YAML manifest supports checked-in and remote specs. 2. CLI check is read-only and detects stale or uncompilable clients. 3. CLI update regenerates checked-in specs. 4. CLI sync validates and vendors remote specs before generation. 5. Tests cover manifest validation, local check/update, remote normalization/drift behavior, and failure reporting. 6. A root action.yml invokes the matching prebuilt CLI. 7. A reusable workflow demonstrates scheduled sync and PR creation. 8. Release automation publishes checksummed binaries for Linux, macOS, and Windows. 9. Documentation includes local and GitHub usage.","notes":"Implementation plan: (1) add src/client_sync.rs with versioned YAML manifest, local/remote spec handling, normalization, check/update/sync orchestration, Cargo check/test, and structured reports; (2) wire subcommands; (3) add root composite action.yml and reusable scheduled PR workflow; (4) extend tag publishing with checksummed native archives; (5) document and test. First version expects an existing generator TOML and Cargo package per client; crate scaffolding/publishing remain consumer policy.","status":"in_progress","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-29T04:57:25Z","created_by":"James Lal","updated_at":"2026-07-29T04:58:37Z","started_at":"2026-07-29T04:57:28Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-x8q","title":"Add manifest-driven client sync and GitHub Action","description":"Add a first-class multi-client lifecycle to openapi-to-rust. A checked-in manifest declares local or remote OpenAPI specs, generator configs, vendored spec paths, and Cargo packages. The CLI supports read-only check, local update, and remote sync; generated clients are compiled and reported. Package the same behavior as a thin GitHub Action/reusable scheduled workflow that can create or update PRs. Publish prebuilt CLI binaries so Actions do not cargo-install the generator.","design":"Keep deterministic lifecycle logic in the existing openapi-to-rust CLI. The Action only installs the matching prebuilt binary and maps GitHub inputs/outputs; GitHub-specific PR orchestration belongs in a reusable workflow. Preserve per-release version alignment between CLI and Action.","acceptance_criteria":"1. A versioned YAML manifest supports checked-in and remote specs. 2. CLI check is read-only and detects stale or uncompilable clients. 3. CLI update regenerates checked-in specs. 4. CLI sync validates and vendors remote specs before generation. 5. Tests cover manifest validation, local check/update, remote normalization/drift behavior, and failure reporting. 6. A root action.yml invokes the matching prebuilt CLI. 7. A reusable workflow demonstrates scheduled sync and PR creation. 8. Release automation publishes checksummed binaries for Linux, macOS, and Windows. 9. Documentation includes local and GitHub usage.","notes":"Implemented on feat/client-sync-action: Clap clients check/update/sync commands, versioned local/remote YAML manifest, normalized remote vendoring, generated-output drift detection, Cargo check/test aggregation, JSON reporting, root composite Action with optional stable PR creation, native release matrix with checksums, documentation, unit/integration fixture coverage, and local Action/release packaging smoke tests. Follow-up adoption is tracked by openapi-generator-onb.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-29T04:57:25Z","created_by":"James Lal","updated_at":"2026-07-29T05:16:45Z","started_at":"2026-07-29T04:57:28Z","closed_at":"2026-07-29T05:16:45Z","close_reason":"Implemented and verified manifest-driven client lifecycle, GitHub Action PR automation, and prebuilt release artifacts.","dependency_count":0,"dependent_count":1,"comment_count":0} {"id":"openapi-generator-1fz","title":"Make scratch Cargo directories portable across CI runners","description":"PR #46 clean Linux runner cannot create hard-coded /private/tmp Cargo target/build directories in generated-crate integration tests. Derive all scratch build paths from each TempDir and verify every affected test plus full CI.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-29T00:10:21Z","created_by":"James Lal","updated_at":"2026-07-29T00:17:39Z","started_at":"2026-07-29T00:10:24Z","closed_at":"2026-07-29T00:17:39Z","close_reason":"Replaced all hard-coded /private/tmp Cargo build and target paths with per-test TempDir paths; focused fresh-build tests, full all-features suite, fmt, and clippy pass.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-6vy","title":"Fix clean-runner scratch-crate dependency resolution","description":"PR #46 CI fails because newly added generated-crate integration tests invoke Cargo with --offline before emitted dependencies such as async-trait exist in a clean runner cache. Remove unconditional offline resolution from the new scratch tests and verify with a cold Cargo cache.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-28T23:59:37Z","created_by":"James Lal","updated_at":"2026-07-29T00:05:51Z","started_at":"2026-07-28T23:59:42Z","closed_at":"2026-07-29T00:05:51Z","close_reason":"Removed unconditional offline mode from all new scratch-crate Cargo invocations; cold-cache focused suite and full all-features CI tests pass.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-60d","title":"Compile-check all 55 supported corpus specs","description":"Run scripts/spec-compile.sh across the complete supported OpenAPI corpus after the Storyden generator fixes, with generation and isolated cargo check for every supported spec.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-28T22:51:36Z","created_by":"James Lal","updated_at":"2026-07-28T23:15:21Z","started_at":"2026-07-28T22:51:41Z","closed_at":"2026-07-28T23:15:21Z","close_reason":"All 55 supported OpenAPI corpus documents generated and compiled successfully with isolated manifests; Microsoft Graph was force-compiled despite the standard resource gate. Gitea remains the expected Swagger 2.0 exclusion.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -54,7 +54,7 @@ {"id":"openapi-generator-cv4","title":"Support exploded query parameters (GH #27)","description":"GH issue 27: query params with style=form, explode=true (OAS defaults) and object schemas currently map to Option\u003cimpl AsRef\u003cstr\u003e\u003e and are sent as one opaque string. Per OAS/RFC6570 form-explode, each object property must become its own query pair (?color=red). Fix: analyzer synthesizes/resolves a typed struct for object query params with form+explode semantics; client generator emits req.query(\u0026struct) so reqwest/serde_urlencoded serializes properties as individual pairs.","notes":"Implemented on branch issue-27-exploded-query-params, draft PR https://github.com/gpu-cli/openapi-to-rust/pull/28. Close when PR merges. Follow-ups: openapi-generator-anu (deepObject/explode=false/arrays), openapi-generator-0jz (server side).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T23:22:24Z","created_by":"James Lal","updated_at":"2026-07-13T04:21:19Z","started_at":"2026-07-12T23:22:49Z","closed_at":"2026-07-13T04:21:19Z","close_reason":"Released in v0.6.0 (PR #28)","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-5q8","title":"DateStrategy::Time emits broken serde codec for time::Date/time::Time (GH #25)","description":"GitHub issue gpu-cli/openapi-to-rust#25: fields with format: date/time under DateStrategy::Time get #[serde(with = \"time::serde::iso8601\")], but that module only supports OffsetDateTime — generated code fails to compile. Fix: emit time::serde::format_description! helper codec modules (time_date_format / time_time_format) into generated code, and correct the time dep requirement features (serde alone doesn't even enable rfc3339).","notes":"Fixed in PR https://github.com/gpu-cli/openapi-to-rust/pull/26 (branch worktree-issue-25-time-date-serde). Close when PR merges.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-11T18:36:28Z","created_by":"James Lal","updated_at":"2026-07-11T18:57:02Z","started_at":"2026-07-11T18:36:45Z","closed_at":"2026-07-11T18:57:02Z","close_reason":"Fixed in PR #26, merged to main, released in v0.5.3","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-dpd","title":"Hybrid string-or-object discriminated unions deserialize-fail","description":"When an anyOf/oneOf union contains a discriminator AND a non-object branch (e.g. string-enum like ToolChoiceOptions), the generator emits a tagged enum that cannot deserialize the string form. Real-world hit: OpenAI ToolChoiceParam returns 'auto' in Response.tool_choice, but generated type is #[serde(tag=\"type\")] enum with no untagged String variant. Need to fall back to #[serde(untagged)] when the union mixes string/scalar branches with tagged-object branches, OR add a String fallback variant before the tagged variants.","notes":"Live repro: `ToolChoiceParam` from openai.yaml line 52518. anyOf has 8 branches; the first (`ToolChoiceOptions`) is a string-enum (\"none\"|\"auto\"|\"required\"), the rest are objects with discriminator propertyName=type. Generator emits `#[serde(tag=\"type\")] enum ToolChoiceParam { ToolChoiceOptions(ToolChoiceOptions), ... }` which cannot deserialize the string \"auto\" because serde tries to read a \"type\" field from a JSON string. Fix: when an anyOf/oneOf branch is a non-object schema (string/number/etc), the generator must emit `#[serde(untagged)]` with the scalar branch first OR add a String variant before the tagged variants. Hit on real OpenAI Responses API `Response.tool_choice` field.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-10T23:47:35Z","created_by":"James Lal","updated_at":"2026-05-11T00:12:46Z","started_at":"2026-05-10T23:54:10Z","closed_at":"2026-05-11T00:12:46Z","close_reason":"Fixed in src/analysis.rs: (dpd) analyze_oneof_union now downgrades to untagged when any branch is non-object — verified live against OpenAI Response.tool_choice='auto' which now deserializes as ToolChoiceParam::ToolChoiceOptions(Auto). (bgo) merge_schema_into_properties now ORs in is_nullable_pattern() for allOf-merged props — verified live against OpenAI Response.incomplete_details which is now Option\u003cResponseIncompleteDetails\u003e and deserializes null cleanly. All 4 smoke tests (OpenAI+Anthropic, sync+stream) pass.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"openapi-generator-onb","title":"Adopt client sync manifest in rust-api-clients","description":"Replace the duplicated scheduled orchestration in the sibling rust-api-clients repository with an openapi-clients.yml manifest and the released gpu-cli/openapi-to-rust Action. Preserve provider overlays, dependency reconciliation, semver classification, smoke tests, and publishing policy that remain repository-specific.","acceptance_criteria":"1. Every current provider/module is represented in openapi-clients.yml. 2. Pull requests run clients check. 3. The scheduled workflow runs clients sync with pull-request creation. 4. Existing normalized drift behavior and compile/test gates remain covered. 5. Any xtask logic made redundant by the generator is removed.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-29T05:16:06Z","created_by":"James Lal","updated_at":"2026-07-29T05:16:06Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-onb","title":"Adopt client sync manifest in rust-api-clients","description":"Replace the duplicated scheduled orchestration in the sibling rust-api-clients repository with an openapi-clients.yml manifest and the released gpu-cli/openapi-to-rust Action. Preserve provider overlays, dependency reconciliation, semver classification, smoke tests, and publishing policy that remain repository-specific.","acceptance_criteria":"1. Every current provider/module is represented in openapi-clients.yml. 2. Pull requests run clients check. 3. The scheduled workflow runs clients sync with pull-request creation. 4. Existing normalized drift behavior and compile/test gates remain covered. 5. Any xtask logic made redundant by the generator is removed.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-29T05:16:06Z","created_by":"James Lal","updated_at":"2026-07-29T05:16:06Z","dependencies":[{"issue_id":"openapi-generator-onb","depends_on_id":"openapi-generator-x8q","type":"blocks","created_at":"2026-07-28T23:16:13Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-d7h","title":"Align wide comparison blocks with narrative column","description":"Comparison headings and prose now align to the standard reading column, but wide tables and other structured blocks still begin at the outer wide-shell edge. Give every article block a shared left edge while preserving extra width to the right for tables and comparison grids.","acceptance_criteria":"On both comparison pages, tables and other structured blocks share the same left edge as their headings and prose; wide blocks continue using available space to the right; mobile overflow remains accessible; build, HTML, SEO, and layout checks pass.","notes":"Applied one responsive --wide-content-inset to every direct child of .prose-wide. Narrative children remain capped at 72ch, while tables, decision grids, command comparisons, code blocks, and pagination use the remaining width to the right. Verified both tables align with their headings in a 2000x3000 local Chrome render. npm run build, audit:html, audit:seo, and the post-change layout detector all pass with zero findings.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-29T03:55:54Z","created_by":"James Lal","updated_at":"2026-07-29T03:57:39Z","started_at":"2026-07-29T03:55:59Z","closed_at":"2026-07-29T03:57:39Z","close_reason":"All comparison-page article blocks now share one left edge while structured content retains its wider rightward span; browser and automated checks pass.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-bkv","title":"Align comparison prose with page heading","description":"The wide comparison layout introduced a mismatched left edge: the hero remains on the standard content column while section headings, callouts, and body prose begin at the expanded table edge. Align ordinary prose content with the hero while preserving wide tables and responsive overflow.","acceptance_criteria":"On both comparison pages, hero copy, section headings, callouts, and body prose share a coherent left edge; comparison tables and other wide data structures still use the expanded track; mobile remains readable; build and site audits pass.","notes":"Fixed the wide comparison layout by adding a shared wide-shell token and applying a responsive narrative inset to direct prose children. The inset is half the difference between the 1180px standard shell and the active wide shell, capped at 130px and collapsing to zero on narrow viewports. Tables, decision grids, command comparisons, and pagination remain on the wide article track. Verified with local Chrome screenshots at 2000px, layout detector (0 findings), npm run build, npm run audit:html, and npm run audit:seo.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-29T03:44:46Z","created_by":"James Lal","updated_at":"2026-07-29T03:48:26Z","started_at":"2026-07-29T03:44:52Z","closed_at":"2026-07-29T03:48:26Z","close_reason":"Hero, callout, section headings, and body prose now share one left edge while wide comparison content retains the expanded track; browser and automated checks pass.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-qav","title":"Let comparison tables use the available desktop width","description":"Fix comparison-page layout so wide tables use the available content column instead of being constrained to the 72ch prose measure, while preserving readable paragraph line lengths, the desktop TOC, and mobile horizontal scrolling.","acceptance_criteria":"Both comparison tables fit their desktop content area without premature horizontal clipping; prose remains capped at the reading measure; mobile tables remain accessible and scrollable; Astro, HTML, and SEO quality gates pass.","notes":"Implemented opt-in wide documentation layout for comparison pages. Article track now fills up to a 1440px shell while direct prose remains capped at the reading width; desktop TOC collapses at 1280px for comparison pages; tables retain accessible horizontal scrolling and narrower mobile sticky columns. Verified with layout detector (0 findings), npm run build, npm run audit:html, and npm run audit:seo.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-29T02:29:03Z","created_by":"James Lal","updated_at":"2026-07-29T02:32:31Z","started_at":"2026-07-29T02:29:10Z","closed_at":"2026-07-29T02:32:31Z","close_reason":"Comparison tables now use available desktop width while prose readability and mobile overflow behavior are preserved; all quality gates pass.","dependency_count":0,"dependent_count":0,"comment_count":0} From 9bf081a84632a4dc63e2bbfb93ba175e6ac1664d Mon Sep 17 00:00:00 2001 From: James Lal Date: Tue, 28 Jul 2026 23:41:22 -0600 Subject: [PATCH 3/3] fix: keep client JSON output machine-readable --- .beads/issues.jsonl | 2 +- src/client_sync.rs | 15 ++++++++--- tests/cli_workflow_test.rs | 25 +++++++++++++++++++ .../fixtures/client-sync/openapi-clients.yml | 1 + 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 7f7240e..b8da730 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,5 @@ {"id":"openapi-generator-dsu","title":"OpenAPI 3.1 type:[X,null] on required properties generates non-Option field","description":"For OpenAPI 3.1 schemas where a property is BOTH listed in 'required' AND declared nullable via the 3.1 type-array form (type: [\"string\",\"null\"]), the generator emits a non-Option field. Deserialization then fails against real responses that send null.\n\nVERIFIED AGAINST THE LIVE RUNPOD v2 API. Two operations fail outright today:\n- GET /v2/catalog/gpus -\u003e GpuType.pool is null in production; spec says type: [\"string\",\"null\"], required. Generated: 'pub pool: String'.\n- GET /v2/pods -\u003e Pod.template is null in production. Generated: 'pub template: String'.\n\nConfirmed affected fields in https://api.runpod.io/v2/openapi.json (4, walking allOf branches):\n GpuType.pool, Pod.dataCenterId, Pod.template, Pod.startedAt\n\nPod.startedAt is the worst latent case: any pod that has not started returns null, which takes out the entire listPods call for that account.\n\nNote Pod composes via allOf, so the required list and properties live in an allOf branch — a fix must traverse composition, not just top-level component properties.\n\nRoot cause reported by live-testing agent (unverified by me): nullability is computed as is_nullable() || is_nullable_pattern() in src/analysis.rs (~1936-1937, ~2506-2514), covering only 3.0 'nullable: true' and the anyOf-with-null shape. Schema::type_array_contains_null() (src/openapi.rs ~649-656) is called from only one site (analysis.rs ~1555). generator.rs (~2497-2517) then yields a plain String.\n\nClosed issue openapi-generator-bgo fixed only the anyOf case; this type-array case is uncovered.\n\nSeverity: 3.1 is the modern dialect and this silently produces a client that cannot read production responses. This is first-run breakage of the worst kind — it compiles, then fails at runtime.","acceptance_criteria":"A required property declared type: [\"string\",\"null\"] generates Option\u003cString\u003e, including when declared inside an allOf branch. Regression test covers both the direct component-schema case and the allOf-composed case. Generating the RunPod v2 spec yields Option for GpuType.pool, Pod.template, Pod.startedAt, and Pod.dataCenterId.","status":"closed","priority":0,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-26T23:51:03Z","created_by":"James Lal","updated_at":"2026-07-27T00:59:39Z","closed_at":"2026-07-27T00:59:39Z","close_reason":"Fixed in fad3ed3; verified against the live RunPod v2 API (list_gpu_types and list_pods now deserialize, with real nulls present in the responses). 362 tests pass.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"openapi-generator-x8q","title":"Add manifest-driven client sync and GitHub Action","description":"Add a first-class multi-client lifecycle to openapi-to-rust. A checked-in manifest declares local or remote OpenAPI specs, generator configs, vendored spec paths, and Cargo packages. The CLI supports read-only check, local update, and remote sync; generated clients are compiled and reported. Package the same behavior as a thin GitHub Action/reusable scheduled workflow that can create or update PRs. Publish prebuilt CLI binaries so Actions do not cargo-install the generator.","design":"Keep deterministic lifecycle logic in the existing openapi-to-rust CLI. The Action only installs the matching prebuilt binary and maps GitHub inputs/outputs; GitHub-specific PR orchestration belongs in a reusable workflow. Preserve per-release version alignment between CLI and Action.","acceptance_criteria":"1. A versioned YAML manifest supports checked-in and remote specs. 2. CLI check is read-only and detects stale or uncompilable clients. 3. CLI update regenerates checked-in specs. 4. CLI sync validates and vendors remote specs before generation. 5. Tests cover manifest validation, local check/update, remote normalization/drift behavior, and failure reporting. 6. A root action.yml invokes the matching prebuilt CLI. 7. A reusable workflow demonstrates scheduled sync and PR creation. 8. Release automation publishes checksummed binaries for Linux, macOS, and Windows. 9. Documentation includes local and GitHub usage.","notes":"Implemented on feat/client-sync-action: Clap clients check/update/sync commands, versioned local/remote YAML manifest, normalized remote vendoring, generated-output drift detection, Cargo check/test aggregation, JSON reporting, root composite Action with optional stable PR creation, native release matrix with checksums, documentation, unit/integration fixture coverage, and local Action/release packaging smoke tests. Follow-up adoption is tracked by openapi-generator-onb.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-29T04:57:25Z","created_by":"James Lal","updated_at":"2026-07-29T05:16:45Z","started_at":"2026-07-29T04:57:28Z","closed_at":"2026-07-29T05:16:45Z","close_reason":"Implemented and verified manifest-driven client lifecycle, GitHub Action PR automation, and prebuilt release artifacts.","dependency_count":0,"dependent_count":1,"comment_count":0} +{"id":"openapi-generator-x8q","title":"Add manifest-driven client sync and GitHub Action","description":"Add a first-class multi-client lifecycle to openapi-to-rust. A checked-in manifest declares local or remote OpenAPI specs, generator configs, vendored spec paths, and Cargo packages. The CLI supports read-only check, local update, and remote sync; generated clients are compiled and reported. Package the same behavior as a thin GitHub Action/reusable scheduled workflow that can create or update PRs. Publish prebuilt CLI binaries so Actions do not cargo-install the generator.","design":"Keep deterministic lifecycle logic in the existing openapi-to-rust CLI. The Action only installs the matching prebuilt binary and maps GitHub inputs/outputs; GitHub-specific PR orchestration belongs in a reusable workflow. Preserve per-release version alignment between CLI and Action.","acceptance_criteria":"1. A versioned YAML manifest supports checked-in and remote specs. 2. CLI check is read-only and detects stale or uncompilable clients. 3. CLI update regenerates checked-in specs. 4. CLI sync validates and vendors remote specs before generation. 5. Tests cover manifest validation, local check/update, remote normalization/drift behavior, and failure reporting. 6. A root action.yml invokes the matching prebuilt CLI. 7. A reusable workflow demonstrates scheduled sync and PR creation. 8. Release automation publishes checksummed binaries for Linux, macOS, and Windows. 9. Documentation includes local and GitHub usage.","notes":"Live cross-repository Action test found that Cargo test output contaminates --json stdout, causing the Action report parser to emit changed=0 and skip PR creation. Reopened to isolate child process output and rerun the end-to-end fixture.","status":"in_progress","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-29T04:57:25Z","created_by":"James Lal","updated_at":"2026-07-29T05:34:58Z","started_at":"2026-07-29T04:57:28Z","dependency_count":0,"dependent_count":1,"comment_count":0} {"id":"openapi-generator-1fz","title":"Make scratch Cargo directories portable across CI runners","description":"PR #46 clean Linux runner cannot create hard-coded /private/tmp Cargo target/build directories in generated-crate integration tests. Derive all scratch build paths from each TempDir and verify every affected test plus full CI.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-29T00:10:21Z","created_by":"James Lal","updated_at":"2026-07-29T00:17:39Z","started_at":"2026-07-29T00:10:24Z","closed_at":"2026-07-29T00:17:39Z","close_reason":"Replaced all hard-coded /private/tmp Cargo build and target paths with per-test TempDir paths; focused fresh-build tests, full all-features suite, fmt, and clippy pass.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-6vy","title":"Fix clean-runner scratch-crate dependency resolution","description":"PR #46 CI fails because newly added generated-crate integration tests invoke Cargo with --offline before emitted dependencies such as async-trait exist in a clean runner cache. Remove unconditional offline resolution from the new scratch tests and verify with a cold Cargo cache.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-28T23:59:37Z","created_by":"James Lal","updated_at":"2026-07-29T00:05:51Z","started_at":"2026-07-28T23:59:42Z","closed_at":"2026-07-29T00:05:51Z","close_reason":"Removed unconditional offline mode from all new scratch-crate Cargo invocations; cold-cache focused suite and full all-features CI tests pass.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-60d","title":"Compile-check all 55 supported corpus specs","description":"Run scripts/spec-compile.sh across the complete supported OpenAPI corpus after the Storyden generator fixes, with generation and isolated cargo check for every supported spec.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-28T22:51:36Z","created_by":"James Lal","updated_at":"2026-07-28T23:15:21Z","started_at":"2026-07-28T22:51:41Z","closed_at":"2026-07-28T23:15:21Z","close_reason":"All 55 supported OpenAPI corpus documents generated and compiled successfully with isolated manifests; Microsoft Graph was force-compiled despite the standard resource gate. Gitea remains the expected Swagger 2.0 exclusion.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/src/client_sync.rs b/src/client_sync.rs index 1e87cbf..e29229f 100644 --- a/src/client_sync.rs +++ b/src/client_sync.rs @@ -9,6 +9,7 @@ use crate::spec_source::{parse_spec, sanitize_source_provenance, validate_oas_do use crate::{CodeGenerator, ConfigFile, SchemaAnalyzer, TypeMapper}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; +use std::io::Write; use std::path::{Path, PathBuf}; use std::process::Command; @@ -501,12 +502,20 @@ fn run_cargo_command( if cargo.locked { command.arg("--locked"); } - let status = command.status()?; - if status.success() { + // Keep the CLI's stdout available for its JSON report. Cargo test harnesses + // write regular output to stdout, which would otherwise corrupt `--json` + // when a caller redirects the command's stdout to a report file. + let output = command.output()?; + let mut stderr = std::io::stderr().lock(); + stderr.write_all(&output.stdout)?; + stderr.write_all(&output.stderr)?; + stderr.flush()?; + if output.status.success() { Ok(()) } else { Err(format!( - "cargo {subcommand} failed for '{}' with {status}", + "cargo {subcommand} failed for '{}' with {}", + output.status, manifest_path.display() ) .into()) diff --git a/tests/cli_workflow_test.rs b/tests/cli_workflow_test.rs index dc8e2bb..12f1b8f 100644 --- a/tests/cli_workflow_test.rs +++ b/tests/cli_workflow_test.rs @@ -56,6 +56,31 @@ fn assert_success(output: &Output) { ); } +#[test] +fn client_check_json_keeps_cargo_output_off_stdout() { + let output = run( + Path::new(env!("CARGO_MANIFEST_DIR")), + &[ + "clients", + "check", + "--manifest", + "tests/fixtures/client-sync/openapi-clients.yml", + "--json", + ], + ); + assert_success(&output); + let report: serde_json::Value = + serde_json::from_slice(&output.stdout).unwrap_or_else(|error| { + panic!( + "client report was not clean JSON: {error}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + }); + assert_eq!(report["mode"], "check"); + assert_eq!(report["clients"][0]["compiled"], true); +} + #[test] fn direct_generation_supports_client_types_only_dry_run_check_quiet_and_json() { let temp = TempDir::new().unwrap(); diff --git a/tests/fixtures/client-sync/openapi-clients.yml b/tests/fixtures/client-sync/openapi-clients.yml index 84ae4ed..e3c6e0d 100644 --- a/tests/fixtures/client-sync/openapi-clients.yml +++ b/tests/fixtures/client-sync/openapi-clients.yml @@ -9,3 +9,4 @@ clients: package: client-sync-fixture all_features: false locked: true + test: true