Skip to content

Collapse three Cargo workspaces into one, defusing the double-serde link - #3

Merged
codeitlikemiley merged 1 commit into
mainfrom
claude/bazel-single-workspace
Aug 2, 2026
Merged

Collapse three Cargo workspaces into one, defusing the double-serde link#3
codeitlikemiley merged 1 commit into
mainfrom
claude/bazel-single-workspace

Conversation

@codeitlikemiley

Copy link
Copy Markdown
Owner

//server:server_bin linked two distinct serde rlibs. This is the fix, and it is proven by a handler that could not have compiled before.

The bug

corex/, server/ and combos/ were three independent Cargo workspaces with three Cargo.lock files, so crate_universe built three separate repos. rules_rust gives every target its own codegen metadata id (--codegen=metadata=-%s), so two Bazel targets built from the same source can never unify. Six crates were doubled: serde, serde_derive, syn, quote, proc-macro2, unicode-ident.

Any corex type crossing into server hit:

error[E0277]: the trait bound `User: Serialize` is not satisfied
note: there are multiple different versions of crate `serde` in the dependency graph

Unfixable from server/BUILD.bazel. It compiled under cargo only because cargo could not see corex at all — the two build systems disagreed by construction. The repo's own guide had already hit this and written it off as "you might encounter version conflicts."

The lockfiles had also already drifted, undetectably: proc-macro2 1.0.96 vs 1.0.97, syn 2.0.104 vs 2.0.105.

Proof, not assertion

server/src/main.rs gains a real handler:

async fn get_shared_user(Path(name): Path<String>) -> Json<corex::User>

That is exactly the line that failed before. It now builds, and a regression test covers it. From the actual graph:

deps(//server:server_bin) → exactly one serde: crates__serde-1.0.229
server_bin deps: axum, corex, serde, tokio

The only crate still appearing twice is syn, at 2.0.119 and 3.0.3 — genuinely different majors, which is correct resolution, not duplication.

What changed

  • New root Cargo.toml: one [workspace], resolver = "3", four members, with [workspace.package] supplying version/edition/rust-version so members inherit one source of truth.
  • Deleted combos/Cargo.toml. Its nested [workspace] would make cargo-bazel's splicer fail outright (manifests are not allowed to be from different workspaces) once combos members joined the root workspace.
  • Deleted all three Cargo.lock files; one root lock, 110 packages.
  • server/Cargo.toml gains corex = { path = "../corex" } so cargo sees the same edge Bazel does. all_crate_deps() never emits workspace members, so //corex:corex_lib stays hand-written.
  • MODULE.bazel: three crate.from_cargo calls → one, named crates.
  • .bazelignore: one root target/ instead of five.
  • CI: cargo now runs once at the root instead of three times, and the doc-test step drops its "does a lib target exist" guard — --workspace --doc only visits members that have one.

Dependency versions moved

Regenerating from scratch re-resolved within the existing semver ranges — no declared constraint changed:

crate before after
axum 0.8.4 0.8.9
serde 1.0.219 1.0.229
tokio 1.47.1 1.53.1
matchit 0.8.4 0.8.6

Reverse any with cargo update -p <crate> --precise <version> if you'd rather pin.

Docs

BAZEL_RUST_GUIDE.md told readers to give every new crate its own Cargo.lock and its own crate.from_cargo repo — the exact policy that produced this bug, scaling quadratically in first-party crates sharing a proc-macro dependency. Following it after this PR would have recreated the defect. Rewritten to the single-workspace recipe.

Its path-dependency ban is corrected too: path deps only break crate_universe when the manifest isn't listed in manifests. Banning them outright is what forced the two graphs apart in the first place.

Verification

check result
cargo fmt --all --check ok
cargo clippy --workspace --all-targets --locked -D warnings ok
cargo test --workspace --all-targets --locked ok
cargo test --workspace --doc --locked ok
bazel build --config=ci //... ok
bazel test --config=ci //... 4/4 pass
bazel build --config=ci --config=lint //... ok

bcr.bazel.build is unreachable from the sandbox this was developed in, so Bazel ran against a local BCR mirror behind a gitignored .bazelrc.user. CI resolves from the real BCR, so its run is the authoritative check.

Sequencing

This should land before the MODULE.bazel.lock work: it changes the module graph, so any lock generated beforehand would immediately need regenerating.


Generated by Claude Code

…erde link

//server:server_bin linked TWO distinct serde rlibs. corex/, server/ and
combos/ were three independent Cargo workspaces with three Cargo.lock files,
so crate_universe built three separate repos, and rules_rust gives every target
its own codegen metadata id (rustc.bzl: --codegen=metadata=-%s), which means two
Bazel targets built from the same source can never unify. Six crates were
doubled that way: serde, serde_derive, syn, quote, proc-macro2, unicode-ident.

The consequence was not theoretical. Any corex type crossing into server hit

  error[E0277]: the trait bound `User: Serialize` is not satisfied
  note: there are multiple different versions of crate `serde`

and it was unfixable from server/BUILD.bazel. It compiled under cargo only
because cargo could not see corex at all -- the two build systems disagreed by
construction. The repo's own guide had already hit this and written it off as
"you might encounter version conflicts".

The lockfiles had also already drifted: proc-macro2 was 1.0.96 in corex and
1.0.97 in server, syn 2.0.104 vs 2.0.105. Nothing could detect it.

What changed
  - New root Cargo.toml: one [workspace], resolver 3, four members, with
    [workspace.package] supplying version/edition/rust-version so members
    inherit one source of truth.
  - Deleted combos/Cargo.toml. Its nested [workspace] table would make
    cargo-bazel's splicer fail outright ("manifests are not allowed to be from
    different workspaces", splicer.rs) once combos members joined the root
    workspace.
  - Deleted all three Cargo.lock files; one root lock, 110 packages.
  - server/Cargo.toml gains `corex = { path = "../corex" }`, so cargo sees the
    same edge Bazel does. all_crate_deps() never emits workspace members, so
    "//corex:corex_lib" stays hand-written in BUILD.bazel.
  - MODULE.bazel: three crate.from_cargo calls -> one, named `crates`.
    The four BUILD files load from @crates.
  - .bazelignore: one root target/ instead of five.
  - CI: cargo now runs once at the root instead of three times, and the doc-test
    step no longer needs its "does a lib target exist" guard, because
    --workspace --doc only visits members that have one.

Proof, not assertion
  server/src/main.rs gains a real handler:

      async fn get_shared_user(Path(name): Path<String>) -> Json<corex::User>

  That is precisely the line that could not compile under Bazel before, and it
  now builds and is covered by a regression test. Verified in the graph:

    deps(//server:server_bin) contains exactly one serde -> crates__serde-1.0.229
    server_bin deps: axum, corex, serde, tokio
    duplicate crates in rust-project.json: syn only, at 2.0.119 and 3.0.3 --
      genuinely different majors, which is correct, not a duplication

Dependency versions moved
  Regenerating from scratch re-resolved within the existing semver ranges:
  axum 0.8.4 -> 0.8.9, serde 1.0.219 -> 1.0.229, tokio 1.47.1 -> 1.53.1,
  matchit 0.8.4 -> 0.8.6. No declared constraint changed. Reverse any of them
  with `cargo update -p <crate> --precise <version>` if a pin is wanted.

Docs
  BAZEL_RUST_GUIDE.md told readers to give every new crate its own Cargo.lock
  and its own crate.from_cargo repo -- the exact policy that produced this bug,
  scaling quadratically in first-party crates sharing a proc-macro dep. Rewritten
  to the single-workspace recipe. Its path-dependency ban is corrected too: path
  deps only break crate_universe when the manifest is not listed in
  `manifests`, and banning them is what forced the two graphs apart.

Verified: cargo fmt/clippy -D warnings/test/doc all green at the workspace root;
bazel build, test (4/4) and --config=lint all green.
NOTE: bcr.bazel.build is unreachable from this sandbox, so Bazel ran against a
local BCR mirror via a gitignored .bazelrc.user. CI resolves from the real BCR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3MwkT9fzAzZweK4birHYz
@codeitlikemiley
codeitlikemiley marked this pull request as ready for review August 2, 2026 03:11
@codeitlikemiley
codeitlikemiley merged commit 0e85dd1 into main Aug 2, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants