Skip to content

bug: additive core config field breaks every generated binding except napi (binding→core From literal omits ..Default::default()) #179

Description

@tobocop2

Problem

The From<Binding> for Core (binding→core) impl alef generates for a public-field config type is a fully exhaustive struct literal, with no ..Default::default() trailer:

impl From<EmbeddingConfig> for xberg::EmbeddingConfig {
    fn from(val: EmbeddingConfig) -> Self {
        Self {
            model: val.model.into(),
            normalize: val.normalize,
            batch_size: val.batch_size,
            show_download_progress: val.show_download_progress,
            cache_dir: val.cache_dir.map(Into::into),
            acceleration: val.acceleration.map(Into::into),
            max_embed_duration_secs: val.max_embed_duration_secs,
        }
    }
}

Because every field is named and there is no spread, the moment a field is added to the core struct — a purely additive, backward-compatible change — this impl stops compiling with error[E0063]: missing field <name> in initializer of <Core>, until the bindings are regenerated. The generated binding cannot survive an additive core change without a regen.

This is inconsistent across backends. napi sets optionalize_defaults, so its binding fields are optional and construction routes through a Default-seeded builder that is forward-compatible:

impl From<JsEmbeddingConfig> for xberg::EmbeddingConfig {
    fn from(val: JsEmbeddingConfig) -> Self {
        let mut __result = xberg::EmbeddingConfig::default();
        if let Some(__v) = val.model { __result.model = __v.into(); }
        // ... a new core field simply keeps its default; still compiles ...
        __result
    }
}

Every other backend that mirrors a core config type — pyo3, magnus (ruby), php, extendr (R), rustler (elixir), wasm, and the dart and swift mirror-crate generators — emits the exhaustive literal and is not forward-compatible.

alef already knows how to emit the trailer: src/codegen/conversions/binding_to_core/render.rs appends ..Default::default() when a field is skipped at generation time (skipped_binding_excluded, ~L238–270) or when the binding strips cfg-gated fields. But when the binding mirrors every current core field — the common case — the trailer is omitted, which is precisely the case that is not forward-compatible. The private-field path (construction.rs) already seeds from Default and is immune.

Concretely in kreuzberg: adding EmbeddingConfig.max_sequence_length: Option<usize> (defaulting to None, a source-only change with no regen) turns the Python, PHP, dart, elixir, and ruby build/E2E CI jobs red on missing field max_sequence_length, even though the change is additive and every binding compiles again after a regen. Any additive field on any core config struct reproduces it.

Solution

When the core type implements Default, always append the ..Default::default() trailer to the public-field binding→core Self { } literal — not only when a field was skipped at generation time. The currently-mapped fields are still assigned explicitly (so no behavior change for existing fields); a field added to the core after generation falls to its core default instead of breaking compilation, matching napi/wasm-style forward-compatibility without changing any backend's binding ergonomics or field optionality.

The existing "don't bypass a bespoke Core::default()" concern that motivated the conditional trailer is unaffected: the trailer only fills fields the impl does not set, and every field the binding knows about is still assigned explicitly above it.

Cross-binding scope (verified against generated output and src/backends/*/gen_bindings/mod.rs conversion configs): the fix belongs in the shared binding_to_core/render.rs public-field path (pyo3/extendr/rustler/wasm route through it) and must be mirrored in the backends that emit their own struct conversion — php (gen_bindings/helpers/struct_conversion.rs), magnus, and the dart/swift mirror-crate generators. napi already sets optionalize_defaults and is immune; the private-field construction.rs path already seeds from Default and is immune.

Present in alef v0.34.0 (main).

Exact condition on main (765002aa1288, v0.34.0), src/codegen/conversions/binding_to_core/render.rs:324:

let emit_trailer = typ.has_stripped_cfg_fields || skipped_binding_excluded;

For a fully-mirrored has_default config type (all fields present, none binding_excluded, no cfg-stripping) both operands are false, so no ..Default::default() trailer is rendered and the Self { } literal is exhaustive. napi avoids this only via optionalize_defaults => builder_mode (the Default-seeded __result builder). The fix is to also force emit_trailer when typ.has_default (and mirror it in the php/magnus/dart/swift struct-conversion generators).

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions