refactor(core)!: add #[non_exhaustive] to the receive-side structs that grew fields this cycle - #2330
Closed
gold-silver-copper wants to merge 3 commits into
Closed
refactor(core)!: add #[non_exhaustive] to the receive-side structs that grew fields this cycle#2330gold-silver-copper wants to merge 3 commits into
gold-silver-copper wants to merge 3 commits into
Conversation
`#[non_exhaustive]` forbids struct-literal construction from other crates, so it can only be added inside a breaking window. This one is open (#2221, v0.42.0) and `Model` just took exactly the break the attribute prevents: #2324 added `max_output_tokens` and broke a downstream struct literal, while the four rig-agent types in the same PR gained fields harmlessly because they carry it. `Model` and `ModelList` are values `list_models()` hands out; callers read them rather than build them. Public fields stay readable and writable, and `Model::from_id`/`Model::new`/`ModelList::new` already cover construction, so the migration is a constructor call plus field assignment. The only construction site the attribute breaks in-tree is this module's own rustdoc, which taught the full-literal form under "Create a model with all fields" — the form anyone following the docs would have written. Doctests compile as external crates, so that example is rewritten to the assignment form and `cargo test --doc` now guards it. The two literals in the in-crate test module are unaffected. Refs #2325
#2221's `cargo-semver-checks` output is a ready-made census of every externally-constructible `rig-core` struct that gained a field this cycle — i.e. every type that already demonstrated it grows fields while lacking `#[non_exhaustive]`. Sixteen of those rows are values the library hands out and callers only read, so they take the attribute too: - `completion::message::ToolCall` (gained `provider`) - `streaming::RawStreamingToolCall` (gained `tool_id`) - anthropic: `CompletionResponse`, `Usage`, `ToolDefinition`, `streaming::{StreamingCompletionResponse, PartialUsage}` - openai: `completion::streaming::StreamingCompletionResponse`, `responses_api::CompletionResponse`, `responses_api::streaming::StreamingCompletionResponse` - gemini: `streaming::StreamingCompletionResponse` - ollama: `ToolCall`, `StreamingCompletionResponse` - cohere: `completion::Usage`, `embeddings::BilledUnits`, `streaming::StreamingCompletionResponse` Two census rows are deliberately skipped, because for them the struct literal *is* the construction contract and no ergonomic alternative exists: `message::ToolResult` has no constructor at all and the public `StreamedUserContent::tool_result` takes one by value, so attributing it would make it unconstructible outside rig-core (14 external literal sites today); `anthropic::completion::AnthropicRequestParams` is request-side input with no `Default`, no `new` and no builder. Both want a constructor first, which is its own change. Only two construction sites break in-tree, both root cassette tests building a cross-provider `message::ToolCall`; `ToolCall::new(id, function)` is exactly equivalent to the literal they used. Every other attributed type already had zero struct-literal sites outside `crates/rig-core/src`. Refs #2325
CHANGELOG gets one breaking entry under Unreleased; MIGRATING gets the literal-to-constructor recipe, a per-type table of the constructor to start from, and the recorded reasons for the two skipped types — so the question stops being re-litigated per PR. Refs #2325
gold-silver-copper
force-pushed
the
refactor/non-exhaustive-listing-types
branch
from
August 15, 2026 03:39
ce88202 to
2bf5056
Compare
Contributor
Author
|
Superseded by #2335, which removes The analysis here is still on record if the decision is ever revisited: #2221's semver output is the census of every externally-constructible |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2325
Why now
#[non_exhaustive]forbids struct-literal construction from other crates, so adding it is itself a semver break — it can only land inside a breaking release window. That window is open: release PR #2221 (v0.42.0) already flagsrig-coreas "⚠ API breaking changes", andModeljust took exactly the break the attribute prevents — #2324 addedModel.max_output_tokensand broke a downstream struct literal, while the fourrig-agenttypes in the same PR (CompletionCall,PromptResponse,ModelTurn,StreamedTurn) gained fields harmlessly because they carry it. Outside a breaking window the attribute cannot be added at all, and #2321/#2079 will keep producing new listing fields in the meantime. #2045 made the same "there is an open window" argument for the error enums and was accepted.Scope: the release window's census, not a general sweep
#2221's
cargo-semver-checksoutput is a ready-made census of every externally-constructiblerig-corestruct that gained a field this cycle — i.e. every type that already demonstrated it grows fields while lacking the attribute. That census is the scope line here, plusModelList(named by #2325; it shares the module and the receive-mostly usage). Deduplicated it is 19 rows; every one is accounted for below, and each was verified to still exist onorigin/mainat the cited path before acting.Note the raw output contains four rows beyond the list in the issue thread —
responses_api/streaming.rs::StreamingCompletionResponse,cohere/streaming.rs::StreamingCompletionResponse,message.rs::ToolResult, and anthropic'sToolDefinition. They are included.Decision rule. Apply the attribute when the type is a value the library hands out and callers overwhelmingly read rather than build. Do not apply it where external construction is the type's contract — concretely: is the struct literal the documented/actual construction idiom, with no ergonomic alternative? That test, not "is it request-shaped", is what separates the two skips below from the 18.
Sweep table
model::Modelmodel/listing.rsmax_output_tokensmodel::ModelListmodel/listing.rscompletion::message::ToolCallcompletion/message.rsproviderstreaming::RawStreamingToolCallstreaming/mod.rstool_idanthropic::completion::CompletionResponseproviders/anthropic/completion.rsprovider_request_idanthropic::completion::Usageproviders/anthropic/completion.rscache_creationanthropic::completion::ToolDefinitionproviders/anthropic/completion.rsstrictanthropic::streaming::StreamingCompletionResponseproviders/anthropic/streaming.rsstop_reason,message_id,model,provider_request_idanthropic::streaming::PartialUsageproviders/anthropic/streaming.rscache_creationopenai::completion::streaming::StreamingCompletionResponseproviders/openai/completion/streaming.rsfinish_reason,response_id,model,provider_request_idopenai::responses_api::CompletionResponseproviders/openai/responses_api/mod.rsopenai::responses_api::streaming::StreamingCompletionResponseproviders/openai/responses_api/streaming.rsstatus,incomplete_details,message_id,response_id,model,provider_request_idgemini::streaming::StreamingCompletionResponseproviders/gemini/streaming.rsresponse_idollama::ToolCallproviders/ollama.rsidollama::StreamingCompletionResponseproviders/ollama.rsmodelcohere::embeddings::BilledUnitsproviders/cohere/embeddings.rsimagescohere::completion::Usageproviders/cohere/completion.rscached_tokenscohere::streaming::StreamingCompletionResponseproviders/cohere/streaming.rsfinish_reason,message_idcompletion::message::ToolResultcompletion/message.rscall,provider,nameanthropic::completion::AnthropicRequestParamsproviders/anthropic/completion.rsstatic_prefix_cache_ttlNo row is unaccounted for; no row had ceased to exist.
Why the 18 qualify
They are all values rig hands out and callers read: model listings, provider-native unary responses, terminal stream records, and usage/billing metadata. Every one is produced inside
rig-core(by serde from a provider body, or by rig's own adapter) and reaches callers throughlist_models()/raw_completion/raw_stream. Across the whole workspace — every other crate, the roottests/crate,examples/, and every rustdoc example — they had a combined three external construction sites, all migrated below.Two deserve a note because they are not obviously "receive" types:
streaming::RawStreamingToolCallis a producer-side primitive that out-of-tree wire adapters build to feed into rig, so it looks like the carve-out. It isn't, under the rule as stated: the struct literal is not its idiom. All three out-of-crate authors (rig-gemini-grpc,rig-candle, and arig-agentexample) already use::new(...)plus field assignment, and every field combination stays reachable —tool_idis a barepubfield andWireId::newis public and re-exported, so an external adapter can reproduce the in-crate gemini pattern that setsidandtool_idindependently.anthropic::completion::ToolDefinitionis request-shaped but rig-produced: it is built only fromrig_core::completion::ToolDefinitioninside apub(super)fn and serialized before leaving. Its one public exposure,AnthropicCompatibleProvider::enable_strict_tool_use, hands out&mut— and#[non_exhaustive]keeps public fields writable.Why the 2 are skipped
completion::message::ToolResult— no constructor at all (impl ToolResultexposes onlywire_call_id; thetool_resulthelpers all returnUserContent/Message, never the struct), while the publicStreamedUserContent::tool_resulttakes one by value. Attributing it would leaveserde_json::from_valueas the only way to build one outsiderig-core— acceptable for a fixture, not for production code, and there are 14 external literal sites today acrossrig-vertexai,rig-memory,rig-gemini-grpc,rig-agentand the root test suite. It wants aToolResult::new/with_providerpair first, which is its own change.anthropic::completion::AnthropicRequestParams— request-side input with noDefault, nonewand no builder, so the struct literal is its only construction path and attributing would leave it with none. It also buys nothing today: its only consumer,AnthropicCompletionRequest, ispub(super), so no downstream crate can use the type at all. (That mismatch — apubtype whose consumer is not public — is worth its own cleanup; not this PR.)The recorded reasons are the point as much as the attributes are, so the question stops being re-litigated per PR (#2325's closing note).
Deliberately out of scope
The census is the scope line. Adjacent
rig-coretypes with the same receive-only profile that did not gain a field this cycle —cohere::completion::BilledUnits,cohere::embeddings::{EmbeddingResponse, Meta, ApiVersion},cohere::completion::{CompletionResponse, Tokens}— are left alone, per #2325's own "a wider sweep is worth its own issue rather than expanding this one". Worth filing if maintainers want them inside this window.One knock-on effect is documented in
MIGRATING.md:cohere::embeddings::Metaholdsbilled_units: BilledUnitsby value and is not itself attributed, so a cross-crate literal ofMetanow needs itsBilledUnitsfrom serde. It is the only place inrig-corewhere an attributed type sits in a public by-value field of an unattributed one, and no in-tree caller does it.Migration recipe
Only cross-crate construction changes. Reading is untouched, public fields stay public and writable, derived
Deserializestill works from any crate, and cross-crate exhaustive destructuring just needs a trailing...MIGRATING.mdcarries the per-type table of which constructor to start from. In-tree this broke exactly five sites, all migrated, none deleted:crates/rig-core/src/model/listing.rs— theModelrustdoc taught the full-literal form under "Create a model with all fields", i.e. the breakable form anyone following the docs would have written. Rewritten to the assignment form; since doctests compile as external crates,cargo test --docnow guards it.tests/providers/{gemini,ollama}/cassette/streaming_grammar.rs— two cross-providermessage::ToolCallliterals, nowToolCall::new(id, function), which is byte-identical (new=Self { id, ..Self::assemble(None, function) }, andassemble(None, …)yieldsprovider: None, signature: None, additional_params: None).tests/providers/anthropic/cassette/empty_stop_sequence_matrix.rs— ananthropic::CompletionResponse+Usagefixture, now built by deserializing a body, which is how the provider path builds one anyway. Neither type has a constructor, and per this PR's scope note no builder is grown here;serde_json::from_valueis the honest fit for a wire-response fixture.tests/providers/anthropic/cassette/stop_sequence_terminal_matrix.rs— twoanthropic::streaming::StreamingCompletionResponsefunctional updates (..Default::default()), nowDefault::default()plus field assignment. Worth noting because functional update is the break that is easy to forget:#[non_exhaustive]blocks..Default::default()cross-crate just as it blocks a full literal.The last two sites are the argument for this PR, in miniature
They did not exist when this branch was cut. They arrived with #2329, merged while this PR was open, and this branch has been rebased onto it. Within a single day, ordinary provider work added two fresh external construction sites to types that were about to grow fields again — which is precisely the churn the attribute converts from "breaking change" into "no change at all". After this PR, the same #2329 could have added those fixtures using
Default::default()+ assignment and no future field addition would have touched them.This is a behavioral no-op: the diff is attributes, five construction-site rewrites, and docs. No serde output changes, no runtime behavior changes.
Verification
Mechanical — all green on this branch:
cargo fmt --allRUSTFLAGS="-D warnings" cargo clippy --workspace --all-targets --all-featurescargo test --workspace --all-featurescargo test --doc --workspace --all-featurescargo check -p rig-core --target wasm32-unknown-unknowncargo doc --workspace --no-depscargo semver-checksv0.48.0 (same version #2221 reports), three runs:1. This diff reports only the expected new break. Baseline
main, currentHEAD:Exactly the 18 attributed types, exactly one lint, nothing else — the break this PR intends and no other.
2. The attribute does what it claims: a future field addition is no longer breaking. On a scratch branch off this one, a dummy
pub dummy_semver_probe: Option<u32>added toModel, baselined against this branch:3. The control — the identical field addition without the attribute is breaking. Same dummy field applied to
maininstead, baselined againstmain:Both scratch branches were discarded. Together (2) and (3) are the A/B: same field, same tool, breaking without the attribute and non-breaking with it.
Independent review. A reviewer given only the diff, cold, swept every external compilation unit for missed literal / functional-update / exhaustive-destructuring sites across all 18 types (accounting for the
ToolCall/CompletionResponse/StreamingCompletionResponse/Usage/ToolDefinition/BilledUnitsname collisions), re-derived the census independently from av0.41.0diff, and verified everyMIGRATING.mdtable row against the code. It found no P1 or P2 and returned SHIP; its four documentation findings — an overstated "last window" claim, a wrong provider-conversion count, a skip rationale that contradicted the paragraph above it, and the undocumentedcohere::embeddings::Metaknock-on — are fixed in the docs commit.