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()?;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'stype_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 | ❌ | LabelledGeneric derive |
| fallible-first, strict leaves | ✅ | try_from |
❌ | ❌ infallible |
| compile error on unmapped field | ✅ | ✅ | ✅ | ✅ |
optionality adaptation (Option<T>↔T with model defaults) |
✅ | ❌ | ❌ | ❌ |
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.
[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.
Every example below is mirrored in magic_map/tests/readme.rs,
so what the README claims is what CI compiles and runs.
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.
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 withcould 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());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.
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.
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.
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);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 {...}).
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.
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 defaultThree behaviors compose, picked automatically per field:
Option<S>source → required dest: unwrap through the funnel;Nonefalls back to the default instance's field value (lives: 9above).Option → Option: stays a plain funnel, soNone → None— an optional destination never gets a value invented.- Plain source →
Option<U>dest: funnel and wrap inSome("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.
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.
Eqcaveat. Enablingvalidateadds aMappingError::Validation(validator::ValidationErrors)variant, andValidationErrorsis onlyPartialEq, soMappingErrorno longer derivesEqin that configuration. Cargo features are additive, so this takes effect build-wide once any crate in the graph turns the feature on.==,match, andassert_eq!are unaffected; only an explicitEqbound (e.g. usingMappingErroras aHashMapkey) is.
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.
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.
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.
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 ApiErroris how?bubbles between layers; an impl where either side's type name ends inErroris 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 TzThe 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.
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.
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.
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".
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.
- The generated code references the crate by its real name —
magic_mapmust be a direct dependency, not renamed. - Two same-named
MagicMaptypes 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 withcould 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 fromTryMapFrom— see that section for the compiler's own reasoning — so leaves are delegated in, andmagic_map_leaves!keeps that from becoming per-consumer bookkeeping.
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.