Skip to content
rsansoresPublic

About

Declaration-site, fallible struct/enum mapping for Rust — automap between types you don't own (DB rows, prost protos, DTOs)

Resources

Stars

3 stars

Watchers

0 watching

Forks

Repository files navigation

magic_map

Upgrading from 0.3? See MIGRATING.md — one rename, three new capabilities.

Declaration-site, fallible struct/enum mapping for Rust — automap between types you don't own (DB rows, prost-generated protos, OpenAPI DTOs) with strict, compile-checked conversions.

use magic_map::{magic_map, TryMapInto};

// The mapping is a standalone declaration — not an attribute on the type.
magic_map!(db::Cat => api::CatDto {
    adopted: false,                  // absent from the source
    big: src.weight_kg > 30.0,       // custom expression
});
// Every other field auto-fills from the same-named source field through a
// fallible leaf funnel: String↔Uuid, Decimal↔f64, Option/Vec wrappers,
// nested mapped enums/structs… and a typo'd or newly-added field is a
// COMPILE error, not a silently-unmapped field.

let dto: api::CatDto = cat.try_map_into()?;

Why another mapper?

Every popular Rust mapping crate (o2o, struct_convert, derive_more) is annotation-site: you put attributes on a struct you own. That collapses in the most common real-world layering — proto types generated by prost-build, rows generated by an ORM, DTOs generated from an OpenAPI spec. There is no struct to annotate, and the orphan rule blocks From impls between two foreign types anyway.

magic_map flips the model:

  • The mapping lives at the call site (your mappers.rs), so the two sides stay completely decoupled — the proto crate never learns about the DB crate and vice versa.
  • Types opt in with a single content-free derive (#[derive(MagicMap)]) that records only the type's own field names. For generated code, inject it through codegen config — e.g. prost-build's type_attribute(".", ...) over the whole proto tree.
  • The fn form generates a plain function instead of a trait impl, so even foreign→foreign mappings (db→proto in a neutral service crate) work.
  • Everything is fallible and strict by default. Lossy conversions return Err(MappingError) instead of inventing a default; unmatched enum variants and absent fields are compile errors.
magic_map o2o derive_more frunk
mapping declared away from the types ✅ ❌ attributes on the type ❌ ❌
both sides generated/foreign ✅ (derive via codegen config, or fn form) ❌ must annotate one side ❌ ⚠️ needs LabelledGeneric derive
fallible-first, strict leaves ✅ ⚠️ opt-in try_from ❌ ❌ infallible
compile error on unmapped field ✅ ✅ ✅ ✅
optionality adaptation (Option<T>↔T with model defaults) ✅ ❌ ❌ ❌

Thanks to o2o

I've used o2o in many projects and it is awesome — if your types are yours to annotate, it's probably what you want. o2o does exactly what Rust philosophy always strives for: be strict, be explicit, do no "magic".

But in a big, deliberately decoupled system it got hard for us: annotations pull layers toward each other (circular-dependency pressure), and the annotation volume grows with every pair of types. After 5k lines of code that exist only to map, you start to consider that some magic is not that bad. That's exactly why this crate is named magic_map.

That said — the magic here comes from conventions (same name ⇒ same concept, strict leaves, fail loudly), and our conventions may not be the ones everyone needs or wants. If you think the library can do better, get in touch on the issue tracker.

Quick start

[dependencies]
magic_map = { version = "0.4", features = ["uuid", "chrono", "decimal"] }

Two layers that never import each other, and an empty mapping declaration — all four fields automap through leaves:

mod db {
    #[derive(magic_map::MagicMap)]
    pub struct Cat {
        pub id: uuid::Uuid,
        pub name: String,
        pub age: i32,
        pub born: chrono::DateTime<chrono::Utc>,
    }
}

mod api {
    #[derive(Debug, magic_map::MagicMap)]
    pub struct CatDto {
        pub id: String,    // Uuid → String automaps (uuid leaf)
        pub name: String,  // identity
        pub age: i64,      // i32 → i64 widens losslessly, automaps
        pub born: String,  // DateTime<Utc> → rfc3339 String (chrono leaf)
    }
}

mod mappers {
    use magic_map::magic_map;
    // The only place that knows both layers.
    magic_map!(super::db::Cat => super::api::CatDto);
}

use magic_map::TryMapInto;

let dto: api::CatDto = db::Cat {
    id: uuid::Uuid::nil(),
    name: "Misifu".into(),
    age: 3,
    born: "2024-01-15T10:30:00Z".parse().unwrap(),
}
.try_map_into()?;

assert_eq!(dto.id, "00000000-0000-0000-0000-000000000000");
assert_eq!(dto.name, "Misifu");
assert_eq!(dto.age, 3_i64);
assert_eq!(dto.born, "2024-01-15T10:30:00+00:00");

The derive is content-free metadata: it publishes the type's own field names as a hidden macro next to the type and nothing else. It never references another layer, so it is safe to apply blanket-style to every model, DTO, and generated proto in a workspace.

Grammar tour

Every example below is mirrored in magic_map/tests/readme.rs, so what the README claims is what CI compiles and runs.

impl form

Generates impl TryMapFrom<Src> for Dest. Legal when your crate owns Src or Dest (the usual db→dto / dto→db case). Override a field when it is absent from the source or needs an expression; everything else automaps:

mod db {
    #[derive(magic_map::MagicMap)]
    pub struct Dog {
        pub name: String,
        pub weight_kg: f32,
    }
}

mod api {
    #[derive(Debug, magic_map::MagicMap)]
    pub struct DogDto {
        pub name: String,   // automaps (identity)
        pub weight_kg: f64, // automaps (f32 → f64 widens)
        pub big: bool,      // absent from the source → must be overridden
    }
}

magic_map!(db::Dog => api::DogDto {
    big: src.weight_kg > 30.0,
});

let dto: api::DogDto = db::Dog { name: "Rex".into(), weight_kg: 38.5 }.try_map_into()?;
assert_eq!(dto.name, "Rex");
assert!(dto.big);

Forgetting big would not compile (missing field), and overriding a field that doesn't exist on DogDto is rejected by the macro — nothing silently disappears in either direction.

fn form

When neither type is local — wire::Lion comes out of prost codegen, db::Lion out of the ORM, and you're mapping them in a neutral service crate — the orphan rule forbids any trait impl. The fn form generates a plain function instead.

One-time setup: a crate that uses the fn form must call magic_map_scope!() once in its crate root. Skip it and the first fn-form mapping fails with could not find `__magic_map_scope` in the crate root.

// src/lib.rs — once per crate
magic_map::magic_map_scope!();
mod wire {
    // The export attribute is only needed because BOTH `Lion`s live in this
    // one example crate — in the real layering they're in different crates.
    #[derive(magic_map::MagicMap)]
    #[magic_map(export = "WireLion")]
    pub struct Lion {
        pub id: String,
        pub name: String,
    }
}

mod db {
    #[derive(Debug, magic_map::MagicMap)]
    pub struct Lion {
        pub id: uuid::Uuid, // String → Uuid parses strictly
        pub name: String,
    }
}

magic_map!(pub fn lion_to_db: wire::Lion => db::Lion);

let row = lion_to_db(wire::Lion {
    id: "67e55044-10b1-426f-9247-bb680e5fe0c8".into(),
    name: "Simba".into(),
})?;
assert_eq!(row.name, "Simba");

// Strict by default: garbage is an Err, not a Uuid::nil().
let bad = lion_to_db(wire::Lion { id: "not-a-uuid".into(), name: "?".into() });
assert!(bad.is_err());

magic_map_scope! — the fn form's crate-local funnel

When you need it: any crate that declares a fn-form mapping. Call it once, at the crate root (lib.rs, main.rs, or the top of an integration test — integration tests are their own crate). Crates whose mappings are all impl form never call it.

Why it exists. A mapping's fields funnel through TryMapFrom too. That is fine for the impl form, which leaves a TryMapFrom impl behind for the next mapping to find. The fn form leaves none — so before this existed, a nested field whose own mapping was also foreign→foreign had nothing to resolve against, and you hand-wrote the recursion:

// the old way — every nested pair spelled out
magic_map!(pub fn postal_code_to_dto: db::PostalCode => dtos::PostalCodeResponse {
    state: state_to_dto(src.state)?,
    locality: src.locality.map(locality_to_dto).transpose()?,
    neighborhoods: src.neighborhoods.into_iter()
        .map(locality_to_dto).collect::<Result<_, _>>()?,
});

magic_map_scope!() plants a trait in your crate. The orphan rule is satisfied by a local trait just as well as by a local type, so impl LocalMapFrom<Address> for AddressResponse is legal there even though both types are foreign — and the fn form now emits one for every mapping it declares. Nested pairs, Option, Vec and the default trailers all compose again:

magic_map!(pub fn state_to_dto:    db::State    => dtos::StateResponse);
magic_map!(pub fn locality_to_dto: db::Locality => dtos::LocalityResponse);

// nested State, Option<Locality>, Vec<Locality> — no overrides needed
magic_map!(pub fn postal_code_to_dto: db::PostalCode => dtos::PostalCodeResponse);

This is what lets mappers live in a crate that owns neither side — a services layer between a *_db crate and a *_dtos crate, with no dependency edge between the two.

Reaching your leaves

Built-in leaves — primitives, String, Uuid, chrono, Decimal, serde_json::Value, the integer widenings — are always present.

Your own come from the crates that own them. A crate declares its leaves once, in its root, with magic_map_leaves!:

// leaf-owning crate, e.g. your db crate
magic_map::magic_map_leaves! {
    identity: [crate::enums::Species],
    display:  [crate::enums::Species],
    parse:    [crate::enums::Species],
    custom: [
        // A pair whose impl you wrote by hand. The impl stays where it is;
        // only the pair is registered, because a macro cannot see an impl.
        crate::wire::Fahrenheit => String,
        // A hand impl that cannot fail is a `MapFrom` impl plus this marker —
        // that one impl then backs both funnels, fallible and infallible.
        infallible crate::wire::Celsius => String,
    ],
}

and every consumer names the crate, never a type:

magic_map::magic_map_scope!(from: [my_db, my_commons]);

Add an enum to that block and it reaches every consumer with no edit on their side. Write your own types as crate::… — those paths are republished as $crate:: so a consumer resolves them against the declaring crate; anything else (String, ::chrono::DateTime<..>) passes through verbatim.

For a one-off pair whose crate has no block, leaves: takes it inline — a bare type is its identity, Src => Dest one direction. A wrapper whose own TryMapFrom impl is generic goes in generic_leaves, ;-separated so the where clause's commas stay unambiguous:

magic_map::magic_map_scope! {
    from: [my_db],
    leaves: [Celsius, Celsius => String],
    generic_leaves: {
        <S, D> Patch<S> => Patch<D> where D: ::magic_map::TryMapFrom<S>;
    },
}

A pair that never arrives fails at the mapping that needed it, naming both types: the trait bound `String: LocalMapFrom<Celsius>` is not satisfied.

Why the trait is a closed world

Two shortcuts look obvious and neither is available.

A blanket bridge forwarding every existing TryMapFrom into the local trait overlaps the per-pair impls, and coherence cannot rule the overlap out because either upstream crate could add the conflicting impl later:

error[E0119]: conflicting implementations of trait `LocalMapFrom<Address>`
              for type `AddressResponse`
   = note: upstream crates may add a new impl of trait
           `TryMapFrom<Address>` for type `AddressResponse` in future versions

Nor can a second, TryMapFrom-backed tier sit underneath to catch leaves. Autoref tiering needs the tiers told apart by receiver shape, as MapFieldOpt/MapFieldVal/MapFieldWrap are; a tier separated only by a where-bound hard-errors rather than falling through. And a concrete per-pair tier is worse than useless: with the destination type still open, probing matches on the source alone and unifies the destination to whatever that impl produces — so a type with both a declared mapping and a leaf (an enum with a DTO twin and a map_display! to String) silently resolves to the wrong one, and the expected type does not override it.

Hence one funnel, leaves delegated in, and magic_map_leaves! so that no consumer ever transcribes them.

let preludes

Shared derivations that feed more than one destination field:

mod geom {
    #[derive(magic_map::MagicMap)]
    pub struct Span {
        pub start: i64,
        pub end: i64,
    }

    #[derive(Debug, magic_map::MagicMap)]
    pub struct SpanStats {
        pub width: i64,
        pub midpoint: i64,
    }
}

magic_map!(pub fn span_stats: geom::Span => geom::SpanStats {
    let width = src.end - src.start;
    width: width,
    midpoint: src.start + width / 2,
});

let stats = span_stats(geom::Span { start: 10, end: 20 })?;
assert_eq!(stats.width, 10);
assert_eq!(stats.midpoint, 15);

Tuple sources

impl TryMapFrom<(A, B, ...)> for Dest — call sites do (a, b).try_map_into()?. Plain non-generic struct elements are open: they must derive MagicMap and their fields join the auto-match. Generic types, references and primitives are opaque: reachable only as src.N in overrides.

mod db {
    #[derive(magic_map::MagicMap)]
    pub struct Cat {
        pub id: i64,
        pub name: String,
        pub age: i32,
    }

    #[derive(magic_map::MagicMap)]
    pub struct Owner {
        pub id: i64,        // collides with Cat::id
        pub name: String,   // collides with Cat::name
    }
}

mod api {
    #[derive(Debug, magic_map::MagicMap)]
    pub struct AdoptionCard {
        pub id: i64,
        pub name: String,
        pub age: i32,
        pub note: Option<String>,
    }
}

magic_map!((db::Cat, db::Owner, Option<String>) => api::AdoptionCard {
    id: src.1.id,     // `id` exists in BOTH Cat and Owner → explicit pick
    name: src.0.name, // same for `name`
    note: src.2,      // exists in neither struct → from the opaque element
});
// `age` automaps — exactly one open element (Cat) has it.

let card: api::AdoptionCard = (
    db::Cat { id: 7, name: "Misifu".into(), age: 3 },
    db::Owner { id: 99, name: "Ricardo".into() },
    Some("indoor only".to_string()),
)
    .try_map_into()?;

assert_eq!(card.id, 99);       // picked from Owner
assert_eq!(card.name, "Misifu"); // picked from Cat
assert_eq!(card.age, 3);       // automapped, unambiguous
assert_eq!(card.note.as_deref(), Some("indoor only"));

A field found in several open elements (or in none) without an override is a compile error naming the field and the candidates — never a silent pick.

A single by-reference source uses a 1-tuple: magic_map!((&Ctx,) => Dest {...}).

Enum mappings

Variant-by-name, generated per source variant — so extra destination variants are fine (never produced), but an unmatched source variant is a compile error. Src => Dest rename pairs handle vocabulary drift, and the proto3 zero variant Unspecified folds to the destination's declared #[default]:

mod wire {
    // prost-style: proto3 forces a zero variant. (The export attribute is
    // only needed because both `Species` live in this one example crate.)
    #[derive(magic_map::MagicMap)]
    #[magic_map(export = "WireSpecies")]
    pub enum Species {
        Unspecified,
        Cat,
        Dog,
        BigCat,
    }
}

mod db {
    #[derive(Debug, Default, PartialEq, magic_map::MagicMap)]
    pub enum Species {
        #[default]
        Cat,
        Dog,
        Lion,
    }
}

magic_map!(pub fn species_to_db: wire::Species => db::Species {
    BigCat => Lion, // explicit rename; the rest pair by name
});

assert_eq!(species_to_db(wire::Species::BigCat)?, db::Species::Lion); // renamed
assert_eq!(species_to_db(wire::Species::Dog)?, db::Species::Dog);     // same name
assert_eq!(species_to_db(wire::Species::Unspecified)?, db::Species::Cat); // zero variant → #[default]

If wire::Species grows a Hamster variant tomorrow, this declaration stops compiling until you decide what Hamster maps to — the drift can't ship.

Defaults — ..DeclaredDefaults and ..AnyDefault

For sparse create/update models, a trailing default makes the mapping default-tolerant: destination fields the source has nothing to say about fall back instead of being a compile error. The destination must implement Default, and business defaults belong on the model (e.g. with smart-default), so every unwrap_or(business_default) line disappears from your mappers.

There are two trailers, because "fall back" covers two unrelated situations and only the caller knows which one they are in:

trailer a field may be omitted when…
(none) never — every destination field must be mapped or overridden
..DeclaredDefaults the model declares its own #[default(..)] for it
..AnyDefault always — whatever Default gives is accepted

Prefer ..DeclaredDefaults. It is the one that keeps the compile-time guarantee you came here for: add a column to the destination and the mapping fails until somebody decides what the column means. ..AnyDefault answers that question in advance, for every column, forever.

..AnyDefault is still right in two places — a patch model, whose whole contract is "a field absent from the request is a column left untouched", and a mapping you have not finished, where it is the honest marker. What it must not be is the way a missing-field error gets silenced.

mod api {
    #[derive(magic_map::MagicMap)]
    pub struct CreateCatRequest {
        pub name: String,
        pub lives: Option<i32>,   // optional on the wire
        pub note: Option<String>,
    }
}

mod db {
    use smart_default::SmartDefault;

    #[derive(Debug, SmartDefault, magic_map::MagicMap)]
    pub struct CreateCat {
        pub name: String,
        #[default = 9]            // the business default lives HERE
        pub lives: i32,           // required in the row
        pub note: Option<String>,
        #[default = "new"]
        pub status: String,       // not on the wire at all
    }
}

magic_map!(api::CreateCatRequest => db::CreateCat { ..DeclaredDefaults });

let row: db::CreateCat = api::CreateCatRequest {
    name: "Misifu".into(),
    lives: None,
    note: None,
}
.try_map_into()?;

assert_eq!(row.name, "Misifu"); // plain funnel
assert_eq!(row.lives, 9);       // None → business default from the model
assert_eq!(row.note, None);     // Option → Option: None stays None
assert_eq!(row.status, "new");  // absent from the request → its declared default

Three behaviors compose, picked automatically per field:

  1. Option<S> source → required dest: unwrap through the funnel; None falls back to the default instance's field value (lives: 9 above).
  2. Option → Option: stays a plain funnel, so None → None — an optional destination never gets a value invented.
  3. Plain source → Option<U> dest: funnel and wrap in Some ("set" semantics), still strict on the way:
mod seen {
    #[derive(magic_map::MagicMap)]
    pub struct CatSeen {
        pub chip_id: String,
        pub weight_kg: f32,
    }

    #[derive(Debug, Default, magic_map::MagicMap)]
    pub struct CatPatch {
        pub chip_id: Option<uuid::Uuid>, // String funnels to Uuid, wraps in Some
        pub weight_kg: Option<f64>,      // f32 widens to f64, wraps in Some
    }
}

magic_map!(seen::CatSeen => seen::CatPatch { ..AnyDefault });

let patch: seen::CatPatch = seen::CatSeen {
    chip_id: "67e55044-10b1-426f-9247-bb680e5fe0c8".into(),
    weight_kg: 4.2,
}
.try_map_into()?;
assert!(patch.chip_id.is_some());

// Still strict: a bad chip_id is an Err — never Some(Uuid::nil()).
let bad: Result<seen::CatPatch, _> = seen::CatSeen {
    chip_id: "not-a-uuid".into(),
    weight_kg: 4.2,
}
.try_map_into();
assert!(bad.is_err());

If None means "don't touch" on your patch models, keep explicit Some(...) wraps in those mappers instead.

status is on neither the request nor the overrides, so ..DeclaredDefaults lets it through only because #[default = "new"] says what it is. Drop that attribute and the mapping stops compiling, naming the field — which is the point. Without any trailer at all, status would need an explicit override.

#[default(..)] is read as an attribute, so any derive using that spelling works; smart-default is the one these examples use. magic_map does not depend on it.

Automatic field validation

When the destination struct derives validator::Validate and annotates fields with #[validate(...)], the mapping automatically calls .validate() on the constructed value before returning it — no change at the call site is needed:

use validator::Validate;

mod api {
    #[derive(magic_map::MagicMap)]
    pub struct CreateUserRequest {
        pub name: String,
        pub email: String,
        pub age: i32,
    }
}

mod db {
    use validator::Validate;

    #[derive(Debug, Validate, magic_map::MagicMap)]
    pub struct NewUser {
        #[validate(length(min = 1, max = 100))]
        pub name: String,
        #[validate(email)]
        pub email: String,
        pub age: i64,
    }
}

magic_map!(api::CreateUserRequest => db::NewUser);

// Valid input → Ok
let user: db::NewUser = api::CreateUserRequest {
    name: "Alice".into(),
    email: "alice@example.com".into(),
    age: 30,
}
.try_map_into()?;

// Invalid input → Err(MappingError::Validation(...))
let bad: Result<db::NewUser, _> = api::CreateUserRequest {
    name: "Alice".into(),
    email: "not-an-email".into(),
    age: 30,
}
.try_map_into();
assert!(matches!(bad, Err(magic_map::MappingError::Validation(_))));

Enable the feature in Cargo.toml:

magic_map = { version = "0.4", features = ["validate"] }

Validation runs after all field conversions succeed — a type error (e.g. a bad UUID parse) surfaces its own MappingError variant before .validate() is ever called.

Eq caveat. Enabling validate adds a MappingError::Validation(validator::ValidationErrors) variant, and ValidationErrors is only PartialEq, so MappingError no longer derives Eq in that configuration. Cargo features are additive, so this takes effect build-wide once any crate in the graph turns the feature on. ==, match, and assert_eq! are unaffected; only an explicit Eq bound (e.g. using MappingError as a HashMap key) is.

Fallible and infallible

Two pairs, mirroring From / TryFrom:

infallible fallible
trait MapFrom / MapInto TryMapFrom / TryMapInto
method map_from / map_into try_map_from / try_map_into
returns Self Result<Self, MappingError>
declared magic_map!(infallible …) magic_map!(…)

A mapping is infallible when every field pair is an identity, a lossless widening, or another infallible mapping. String → Uuid parses, so it is not.

magic_map!(infallible db::Tenant => wire::Tenant);

let t: wire::Tenant = row.map_into();   // no `?`

The claim is checked rather than trusted: the expansion contains no ?, so a field pair with only a TryMapFrom route fails to resolve. Infallible mappings also get the fallible half generated, so try_map_into() keeps working on them.

Enum mappings can be infallible too — variant-to-variant over unit enums carries no decision. And infallible fn-forms compose: magic_map_scope! plants an infallible local funnel beside the fallible one, so an infallible fn mapping nests another foreign→foreign infallible fn mapping exactly as fallible ones always nested. What flows into that funnel is decided by the leaves — see leaf fallibility.

Sources may be borrowed:

magic_map!(infallible fn fiscal: &CompanyPayload => FiscalFields { … });

One restriction: a default trailer cannot be infallible — the default funnel is fallible by construction, and the macro says so.

Sealing — #[mapped(sealed)]

The failure worth preventing is not a wrong From impl. It is the field-by-field copy typed straight into a service or a controller, using no trait at all — which no linter catches reliably, since a grep cannot tell construction from destructuring.

#[mapped(sealed)] adds #[non_exhaustive] and a hidden all-fields constructor. From any other crate the type then has no struct expression:

#[mapped(sealed)]                       // must come before the derives
#[derive(Serialize, Deserialize, Clone)]
pub struct CustomerDto { … }
error[E0639]: cannot create non-exhaustive struct using struct expression

magic_map! builds through the constructor, so declared mappings are unaffected. Banning impl From needs no separate feature — a From impl for a sealed type cannot construct its own output either.

It is an attribute, not a derive, because a derive is additive-only and can never place an attribute on the item it derives. It is #[mapped] rather than #[magic_map] because the latter would collide with the magic_map! macro.

Sealing reaches any type whose owning crate is not the one doing the mapping — in a layered codebase, that is DTOs, database models and generated protos. It does not reach types local to the mapping crate, conversions out of a sealed type, or anything not sealed. #[mapped] with no argument is exactly #[derive(MagicMap)], so adoption is per-type.

#[mapped] skips shapes where sealing would only cost: unit and tuple structs, enums, and field-less markers such as a proto Empty.

For the three places sealing cannot reach, there is the lint.

Sparse updates — #[mapped(sealed, patch)]

Sealing assumes every construction is a mapping. One is not: a sparse update or query model built from nothing. A state transition's values are enum literals, Utc::now(), a freshly generated key — there is no source struct, so no magic_map! declaration can express it, and sealing leaves the owning crate hand-writing a setter per field.

patch generates them:

#[mapped(sealed, patch)]
#[derive(Default)]
pub struct UpdateDevice {
    pub status: Option<DeviceStatus>,
    pub last_seen_at: Patch<DateTime<Utc>>,
    pub api_key: Patch<String>,
}
// from a service crate, which cannot write the struct expression:
db.device.update(id, UpdateDevice::patch()
    .status(Some(DeviceStatus::Provisioned))
    .last_seen_at(Patch::Set(Utc::now()))).await?;

patch() is Self::default(), so the type must implement Default; each setter consumes and returns Self. It is not a builder type and there is no .build() — every field of a patch model already has a default, so there is nothing to validate at the end and a partly-filled T is a legal T. A field actually named patch is a compile error naming the collision.

This lowers the seal; it does not pierce it. A whole-struct copy is still writable as a twenty-line setter chain — but twenty visible lines is not the failure mode sealing exists to stop. That was the single invisible ..Default::default(), and it is still gone.

Use patch where construction-from-nothing is the type's job — Update*, *Query — and not on types that are only ever mapping destinations, where it would add reachable setters for no caller.

Linting — magic-map-lint

Sealing is a compile-time wall, but it has three blind spots: types local to the mapping crate, conversions out of a sealed type, and anything you chose not to seal. The escape hatch that grows back in all three is a hand-written std conversion impl — so the repo ships a linter that finds exactly that.

magic_map_lint is a standalone binary crate (syn-based, so it tells an impl from a use and needs no compilation of your code):

cargo install magic_map_lint
magic-map-lint --allow .magic-map-allow src/ crates/

It walks the given paths and flags every impl From / Into / TryFrom / TryInto, with two deliberate exemptions:

  • Error conversions. impl From<MappingError> for ApiError is how ? bubbles between layers; an impl where either side's type name ends in Error is not a data mapping.

  • The allowlist — one rendered signature per line, # for comments:

    # newtype ↔ inner ergonomics, not a layer mapping
    impl From<Tz> for TimeZone
    impl From<TimeZone> for Tz
    

    The list only shrinks: an entry that no longer matches anything fails the run, so paid-off debt cannot linger and hide new violations.

Exit code 1 on any violation or stale entry — wire it into your lint recipe / CI next to clippy. The intended division of labour: seal what must only be built by declared mappings, lint the conversion impls everywhere else.

Leaves

A leaf is a conversion impl for a known type pair — TryMapFrom always, plus MapFrom when the conversion cannot fail (identities, widenings, Uuid→ String, Display routes), which is what lets it appear inside infallible mappings. Identities for primitives and String ship always; third-party leaves are feature-gated:

feature leaves / behavior
uuid Uuid identity, String↔Uuid (strict parse)
chrono date/time identities, DateTime<Utc>↔String (rfc3339), NaiveDate↔String (ISO-8601)
decimal Decimal identity, Decimal↔f64 / Decimal↔String (strict — NaN/∞ error)
json serde_json::Value identity
validate MappingError::Validation variant; auto-validates destinations with #[validate(...)] fields
full all of the above

Lossless integer widenings (u8→u16…i64, i32→i64, f32→f64) automap; narrowing stays an explicit as cast in an override, where the lossiness is visible.

Your own leaves

Declare them in the crate that owns the type — they then automap everywhere. The macros pair naturally with strum's Display/EnumString derives:

#[derive(strum::Display, strum::EnumString, magic_map::MagicMap)]
pub enum Species {
    Cat,
    Dog,
    Lion,
}

magic_map::map_identity!(Species); // Species → Species (model→model moves)
magic_map::map_display!(Species);  // Species → String fields automap
magic_map::map_parse!(Species);    // String → Species fields automap, strictly

// Or hand-write any pair:
impl magic_map::TryMapFrom<MyWireTimestamp> for chrono::DateTime<chrono::Utc> {
    fn try_map_from(src: MyWireTimestamp) -> Result<Self, magic_map::MappingError> {
        /* strict conversion */
    }
}

Leaf fallibility. map_identity! and map_display! emit the infallible MapFrom twin automatically — an identity or a Display cannot fail — so those routes work inside infallible mappings out of the box. map_parse! stays fallible. A hand-written pair that cannot fail writes one MapFrom impl and registers with the infallible prefix in magic_map_leaves! (shown above); a pair left unmarked keeps working fallibly — marking is only what lets it serve infallible declarations.

These automap everywhere the impl form is used. To reach them from a fn-form mapping too, declare them with magic_map_leaves! instead — same impls, plus the published list a consumer's scope replays.

prost / generated-code recipe

In build.rs, plant the schema on every generated type — and seal the packages that are mapping destinations, path-scoped, so the declared mapping is the only way to build one outside the proto crate. Request/args/confirm packages stay on the plain attribute: clients assemble those from local state, which is parameter construction, not mapping.

prost_build::Config::new()
    // model types: schema + seal — hand-rolled copies stop compiling
    .type_attribute(".mypkg.models", "#[magic_map::mapped(sealed)]")
    // message/args types: schema only — still constructible by clients
    .type_attribute(".mypkg.rpc", "#[magic_map::mapped]")
    .compile_protos(&["proto/models.proto", "proto/rpc.proto"], &["proto/"])?;

Unsupported shapes (tuple structs, enums with payload variants) are a silent no-op, and sealing skips unit/zero-field markers such as a proto Empty, so package-level attributes are safe. If two same-named types exist in one crate (e.g. pkg_a.Sale and pkg_b.Sale), disambiguate one's hidden export — either form works: #[magic_map(export = "PkgASale")] next to a derive, or #[magic_map::mapped(sealed, export = "PkgASale")] in one attribute.

Then map proto↔db in a service crate with the fn form — no crate ever depends on the other's "shape".

Error handling philosophy

Every mapping returns Result<_, MappingError>. Convert it once into each layer's error type (impl From<MappingError> for ApiError) and bubble with ?. Mappers fail loudly; quarantining/skipping bad records is a decision for the handler that owns the batch, not for the conversion.

Limitations

  • The generated code references the crate by its real name — magic_map must be a direct dependency, not renamed.
  • Two same-named MagicMap types in one crate collide on the hidden crate-root export — rename one or use #[magic_map(export = "...")].
  • A type re-exported at a crate root (pub use context::TenantContext) does not carry its schema alias along — a destination path must go through the defining module (commons::context::TenantContext) or a module re-export, or the mapping fails with could not find `__magic_map_schema_…` .
  • Destination types must have named fields (or unit variants); tuple structs are not supported as destinations.
  • The fn form needs magic_map_scope! in the crate root, naming the crates whose leaves it uses. Coherence allows no automatic bridge from TryMapFrom — see that section for the compiler's own reasoning — so leaves are delegated in, and magic_map_leaves! keeps that from becoming per-consumer bookkeeping.

License

Licensed under either of Apache License, Version 2.0 or MIT license at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.

About

Declaration-site, fallible struct/enum mapping for Rust — automap between types you don't own (DB rows, prost protos, DTOs)

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages