From b1166a1030b180f6a26c63bd5278c57e8384676b Mon Sep 17 00:00:00 2001 From: Ricardo Sansores Date: Thu, 20 Aug 2026 12:08:53 -0600 Subject: [PATCH 01/10] feat: infallible mappings, reference sources, and #[mapped(sealed)] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes, and a rename that should have happened at 0.1. MapFrom/map_into were the fallible pair, which left every call site writing `?` for a conversion that cannot fail and forced enclosing functions to return Result for no reason. They are now TryMapFrom/try_map_into, mirroring From/TryFrom, and the plain names belong to a new infallible pair. `magic_map!(infallible ...)` emits it. The claim is checked structurally rather than trusted: the expansion contains no `?`, so a field pair that only has a TryMapFrom route fails to resolve. String -> Uuid parses, so claiming infallible over it does not compile. Infallible mappings also get the fallible half for free, written out rather than blanket-impl'd because `impl> TryMapFrom for D` overlaps every generated impl and coherence rejects it. Sources may now be `&T`. The tuple form already took references in element position; only the top-level parse refused, which left a borrowed source spelled as a one-tuple. `#[mapped]` is the attribute form of the derive, and `#[mapped(sealed)]` adds #[non_exhaustive] plus a hidden all-fields constructor. From another crate the type then has no struct expression, so a hand-rolled field-by-field copy stops compiling — and so does `impl From for T`, because that impl body cannot construct its own output either. Forbidding the manual map forbids the manual From with it; they are one mechanism, not two. It has to be an attribute because a derive is additive-only and can never place an attribute on the item it is deriving. The constructor is public: an expansion holds no privilege a hand-written line lacks, so this makes the wrong path ugly and greppable rather than impossible. Sealing is skipped where it would only cost — unit and tuple structs, enums, and field-less markers like a proto Empty. --- README.md | 48 ++--- compat/no-validate-feature/src/lib.rs | 4 +- leaf_provider/src/lib.rs | 25 ++- magic_map/src/lib.rs | 188 +++++++++++------- magic_map/tests/infallible.rs | 88 +++++++++ magic_map/tests/magic_map.rs | 62 +++--- magic_map/tests/readme.rs | 32 +-- magic_map/tests/sealed.rs | 58 ++++++ magic_map_macros/src/lib.rs | 36 +++- magic_map_macros/src/magic_map.rs | 272 +++++++++++++++++++++++--- 10 files changed, 631 insertions(+), 182 deletions(-) create mode 100644 magic_map/tests/infallible.rs create mode 100644 magic_map/tests/sealed.rs diff --git a/README.md b/README.md index 31e8f7e..712b32f 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ types you **don't own** (DB rows, prost-generated protos, OpenAPI DTOs) with strict, compile-checked conversions. ```rust -use magic_map::{magic_map, MapInto}; +use magic_map::{magic_map, TryMapInto}; // The mapping is a standalone declaration — not an attribute on the type. magic_map!(db::Cat => api::CatDto { @@ -17,7 +17,7 @@ magic_map!(db::Cat => api::CatDto { // 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.map_into()?; +let dto: api::CatDto = cat.try_map_into()?; ``` ## Why another mapper? @@ -112,7 +112,7 @@ mod mappers { magic_map!(super::db::Cat => super::api::CatDto); } -use magic_map::MapInto; +use magic_map::TryMapInto; let dto: api::CatDto = db::Cat { id: uuid::Uuid::nil(), @@ -120,7 +120,7 @@ let dto: api::CatDto = db::Cat { age: 3, born: "2024-01-15T10:30:00Z".parse().unwrap(), } -.map_into()?; +.try_map_into()?; assert_eq!(dto.id, "00000000-0000-0000-0000-000000000000"); assert_eq!(dto.name, "Misifu"); @@ -140,7 +140,7 @@ so what the README claims is what CI compiles and runs. ### impl form -Generates `impl MapFrom for Dest`. Legal when your crate owns `Src` or +Generates `impl TryMapFrom 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: @@ -166,7 +166,7 @@ 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 }.map_into()?; +let dto: api::DogDto = db::Dog { name: "Rex".into(), weight_kg: 38.5 }.try_map_into()?; assert_eq!(dto.name, "Rex"); assert!(dto.big); ``` @@ -232,8 +232,8 @@ 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 `MapFrom` too. That is -fine for the impl form, which leaves a `MapFrom` impl behind for the next +**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: @@ -299,7 +299,7 @@ side. Write your own types as `crate::…` — those paths are republished as 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 `MapFrom` +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: @@ -308,7 +308,7 @@ magic_map::magic_map_scope! { from: [my_db], leaves: [Celsius, Celsius => String], generic_leaves: { - Patch => Patch where D: ::magic_map::MapFrom; + Patch => Patch where D: ::magic_map::TryMapFrom; }, } ``` @@ -320,7 +320,7 @@ types: ``the trait bound `String: LocalMapFrom` is not satisfied``. Two shortcuts look obvious and neither is available. -A blanket bridge forwarding every existing `MapFrom` into the local trait +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: @@ -328,10 +328,10 @@ either upstream crate could add the conflicting impl later: error[E0119]: conflicting implementations of trait `LocalMapFrom
` for type `AddressResponse` = note: upstream crates may add a new impl of trait - `MapFrom
` for type `AddressResponse` in future versions + `TryMapFrom
` for type `AddressResponse` in future versions ``` -Nor can a second, `MapFrom`-backed tier sit underneath to catch leaves. +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 @@ -376,7 +376,7 @@ assert_eq!(stats.midpoint, 15); ### Tuple sources -`impl MapFrom<(A, B, ...)> for Dest` — call sites do `(a, b).map_into()?`. +`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. @@ -419,7 +419,7 @@ let card: api::AdoptionCard = ( db::Owner { id: 99, name: "Ricardo".into() }, Some("indoor only".to_string()), ) - .map_into()?; + .try_map_into()?; assert_eq!(card.id, 99); // picked from Owner assert_eq!(card.name, "Misifu"); // picked from Cat @@ -521,7 +521,7 @@ let row: db::CreateCat = api::CreateCatRequest { lives: None, note: None, } -.map_into()?; +.try_map_into()?; assert_eq!(row.name, "Misifu"); // plain funnel assert_eq!(row.lives, 9); // None → business default from the model @@ -559,7 +559,7 @@ let patch: seen::CatPatch = seen::CatSeen { chip_id: "67e55044-10b1-426f-9247-bb680e5fe0c8".into(), weight_kg: 4.2, } -.map_into()?; +.try_map_into()?; assert!(patch.chip_id.is_some()); // Still strict: a bad chip_id is an Err — never Some(Uuid::nil()). @@ -567,7 +567,7 @@ let bad: Result = seen::CatSeen { chip_id: "not-a-uuid".into(), weight_kg: 4.2, } -.map_into(); +.try_map_into(); assert!(bad.is_err()); ``` @@ -620,7 +620,7 @@ let user: db::NewUser = api::CreateUserRequest { email: "alice@example.com".into(), age: 30, } -.map_into()?; +.try_map_into()?; // Invalid input → Err(MappingError::Validation(...)) let bad: Result = api::CreateUserRequest { @@ -628,7 +628,7 @@ let bad: Result = api::CreateUserRequest { email: "not-an-email".into(), age: 30, } -.map_into(); +.try_map_into(); assert!(matches!(bad, Err(magic_map::MappingError::Validation(_)))); ``` @@ -654,7 +654,7 @@ ever called. ## Leaves -A *leaf* is a `MapFrom` impl for a known type pair. Identities for primitives +A *leaf* is a `TryMapFrom` impl for a known type pair. Identities for primitives and `String` ship always; third-party leaves are feature-gated: | feature | leaves / behavior | @@ -688,8 +688,8 @@ 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::MapFrom for chrono::DateTime { - fn map_from(src: MyWireTimestamp) -> Result { +impl magic_map::TryMapFrom for chrono::DateTime { + fn try_map_from(src: MyWireTimestamp) -> Result { /* strict conversion */ } } @@ -736,7 +736,7 @@ decision for the handler that owns the batch, not for the conversion. are not supported as destinations. - The fn form needs [`magic_map_scope!`](#magic_map_scope--the-fn-forms-crate-local-funnel) in the crate root, naming the crates whose leaves it uses. Coherence allows no - automatic bridge from `MapFrom` — see that section for the compiler's own + 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. diff --git a/compat/no-validate-feature/src/lib.rs b/compat/no-validate-feature/src/lib.rs index 22b7f79..ed51f6a 100644 --- a/compat/no-validate-feature/src/lib.rs +++ b/compat/no-validate-feature/src/lib.rs @@ -7,7 +7,7 @@ //! does not exist here) — it just maps the fields. If this crate compiles, the //! gating holds. -use magic_map::{magic_map, MagicMap, MapInto}; +use magic_map::{magic_map, MagicMap, TryMapInto}; use validator::Validate; #[derive(MagicMap)] @@ -33,6 +33,6 @@ pub fn maps_without_validating() -> ValidatedDto { name: String::new(), email: "not-an-email".into(), } - .map_into() + .try_map_into() .unwrap() } diff --git a/leaf_provider/src/lib.rs b/leaf_provider/src/lib.rs index b335aca..12064a9 100644 --- a/leaf_provider/src/lib.rs +++ b/leaf_provider/src/lib.rs @@ -1,6 +1,6 @@ //! A leaf-owning crate, as a consumer of `magic_map` would write one. -// One block, in the crate root. Emits the `MapFrom` impls and publishes the +// One block, in the crate root. Emits the `TryMapFrom` impls and publishes the // pair list; nothing downstream restates any of it. magic_map::magic_map_leaves! { identity: [crate::enums::Species], @@ -25,8 +25,8 @@ pub mod wire { // Hand-written, so no macro can see it — the pair is registered in the // block above instead. - impl magic_map::MapFrom for String { - fn map_from(src: Fahrenheit) -> Result { + impl magic_map::TryMapFrom for String { + fn try_map_from(src: Fahrenheit) -> Result { Ok(format!("{}F", src.0)) } } @@ -39,3 +39,22 @@ pub struct Reading { pub species_label: enums::Species, pub temp: wire::Fahrenheit, } + +// ── a sealed type, to be built from another crate ──────────────────────────── +/// Sealed: no other crate can write a struct expression for this, so the only +/// way in is `magic_map!` (which uses the hidden constructor) or the deliberate, +/// greppable call. +#[magic_map::mapped(sealed)] +#[derive(Debug, PartialEq, Default)] +pub struct SealedDto { + pub id: String, + pub count: u64, +} + +/// The same shape, unsealed, as the control. +#[magic_map::mapped] +#[derive(Debug, PartialEq, Default)] +pub struct OpenDto { + pub id: String, + pub count: u64, +} diff --git a/magic_map/src/lib.rs b/magic_map/src/lib.rs index f10b731..963ec11 100644 --- a/magic_map/src/lib.rs +++ b/magic_map/src/lib.rs @@ -3,7 +3,7 @@ //! `magic_map!` declares a mapping between two types as a standalone //! statement — not as attributes on the types. Every destination field //! without an explicit override is auto-filled from the same-named source -//! field through the [`MapFrom`] leaf funnel, so identities, `String↔Uuid`, +//! field through the [`TryMapFrom`] leaf funnel, so identities, `String↔Uuid`, //! `Decimal↔f64`, `Option`/`Vec` wrappers, and previously-mapped enums and //! structs compose for free — and every conversion that can lose information //! is fallible and surfaces a [`MappingError`]. @@ -21,7 +21,7 @@ //! including why custom leaves have to be named there. //! //! ``` -//! use magic_map::{magic_map, MagicMap, MapInto}; +//! use magic_map::{magic_map, MagicMap, TryMapInto}; //! //! mod db { //! #[derive(magic_map::MagicMap)] @@ -51,7 +51,7 @@ //! name: "Ada".into(), //! age: 36, //! } -//! .map_into() +//! .try_map_into() //! .unwrap(); //! assert_eq!(dto.age, 36); //! assert!(!dto.vip); @@ -76,13 +76,13 @@ //! | `full` | all of the above | //! //! Leaves for your **own** types are declared with [`map_identity!`], -//! [`map_display!`], [`map_parse!`], or a plain `MapFrom` impl in the crate +//! [`map_display!`], [`map_parse!`], or a plain `TryMapFrom` impl in the crate //! that owns the type. use std::error::Error; use std::fmt; -pub use magic_map_macros::{magic_map, magic_map_leaves, MagicMap}; +pub use magic_map_macros::{magic_map, magic_map_leaves, MagicMap, mapped}; #[doc(hidden)] pub use magic_map_macros::__magic_map_expand; @@ -185,33 +185,59 @@ impl From for MappingError { /// Fallible field/struct/enum conversion. Implemented by `magic_map!` for /// structs and enums, and by the leaf impls below for known type pairs. /// -/// Orphan-rule note: a `MapFrom for Dest` impl is only legal in a crate +/// Orphan-rule note: a `TryMapFrom for Dest` impl is only legal in a crate /// that owns `Dest` or `Src`. Mappings where one side is local use the impl /// form of `magic_map!`. Foreign→foreign mappings (e.g. db→proto in a neutral /// service crate) cannot carry a trait impl at all — use the fn form /// (`magic_map!(pub fn name: Src => Dest)`), which still reuses the leaf /// conversions. +pub trait TryMapFrom: Sized { + fn try_map_from(src: S) -> Result; +} + +/// Call-side ergonomics: `let dto: Dto = db.try_map_into()?;` +pub trait TryMapInto { + fn try_map_into(self) -> Result; +} +impl> TryMapInto for S { + fn try_map_into(self) -> Result { + D::try_map_from(self) + } +} + +/// Infallible struct/enum conversion — the half of the funnel that carries no +/// decision. A mapping is infallible when every field pair is: an identity, a +/// lossless widening, or another infallible mapping. `String` → `Uuid` is not, +/// and that asymmetry is the whole point — a conversion that can fail says so +/// in its type, and one that cannot does not make every call site pretend. +/// +/// `magic_map!(infallible ...)` emits this. The check is structural rather +/// than declarative: the expansion has no `?` in it, so a field pair that only +/// has `TryMapFrom` fails to resolve. You cannot claim infallible wrongly. pub trait MapFrom: Sized { - fn map_from(src: S) -> Result; + fn map_from(src: S) -> Self; } -/// Call-side ergonomics: `let dto: Dto = db.map_into()?;` +/// Call-side ergonomics: `let dto: Dto = db.map_into();` — no `?`. pub trait MapInto { - fn map_into(self) -> Result; + fn map_into(self) -> D; } impl> MapInto for S { - fn map_into(self) -> Result { + fn map_into(self) -> D { D::map_from(self) } } /// Identity conversions for known leaf types. Deliberately NOT a blanket -/// `impl MapFrom for T` — that overlaps the `Option`/`Vec` wrappers and +/// `impl TryMapFrom for T` — that overlaps the `Option`/`Vec` wrappers and /// fails coherence. Add a new leaf in one line. macro_rules! leaf_identity { ($($t:ty),* $(,)?) => {$( + impl TryMapFrom<$t> for $t { + fn try_map_from(src: $t) -> Result { Ok(src) } + } impl MapFrom<$t> for $t { - fn map_from(src: $t) -> Result { Ok(src) } + fn map_from(src: $t) -> Self { src } } )*}; } @@ -237,11 +263,14 @@ leaf_identity!(serde_json::Value); /// is visible. macro_rules! leaf_widen { ($($s:ty => $d:ty),+ $(,)?) => {$( - impl MapFrom<$s> for $d { - fn map_from(src: $s) -> Result { + impl TryMapFrom<$s> for $d { + fn try_map_from(src: $s) -> Result { Ok(<$d>::from(src)) } } + impl MapFrom<$s> for $d { + fn map_from(src: $s) -> Self { <$d>::from(src) } + } )+}; } leaf_widen!( @@ -254,16 +283,27 @@ leaf_widen!( f32 => f64, ); -impl> MapFrom> for Option { - fn map_from(src: Option) -> Result { +impl> TryMapFrom> for Option { + fn try_map_from(src: Option) -> Result { match src { - Some(s) => Ok(Some(D::map_from(s)?)), + Some(s) => Ok(Some(D::try_map_from(s)?)), None => Ok(None), } } } +impl> TryMapFrom> for Vec { + fn try_map_from(src: Vec) -> Result { + src.into_iter().map(D::try_map_from).collect() + } +} + +impl> MapFrom> for Option { + fn map_from(src: Option) -> Self { + src.map(D::map_from) + } +} impl> MapFrom> for Vec { - fn map_from(src: Vec) -> Result { + fn map_from(src: Vec) -> Self { src.into_iter().map(D::map_from).collect() } } @@ -272,61 +312,71 @@ impl> MapFrom> for Vec { #[cfg(feature = "uuid")] mod uuid_leaves { - use super::{MapFrom, MappingError}; + use super::{MapFrom, TryMapFrom, MappingError}; use uuid::Uuid; - impl MapFrom for Uuid { - fn map_from(src: String) -> Result { + impl TryMapFrom for Uuid { + fn try_map_from(src: String) -> Result { Uuid::parse_str(&src).map_err(|_| MappingError::InvalidUuid { field: "" }) } } - impl MapFrom for String { - fn map_from(src: Uuid) -> Result { + impl TryMapFrom for String { + fn try_map_from(src: Uuid) -> Result { Ok(src.to_string()) } } + impl MapFrom for String { + fn map_from(src: Uuid) -> Self { + src.to_string() + } + } } #[cfg(feature = "decimal")] mod decimal_leaves { - use super::{MapFrom, MappingError}; + use super::{MapFrom, TryMapFrom, MappingError}; use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; - impl MapFrom for f64 { - fn map_from(src: Decimal) -> Result { + impl TryMapFrom for f64 { + fn try_map_from(src: Decimal) -> Result { src.to_f64() .ok_or(MappingError::OutOfRange { field: "" }) } } - impl MapFrom for Decimal { - fn map_from(src: f64) -> Result { + impl TryMapFrom for Decimal { + fn try_map_from(src: f64) -> Result { // NaN/±inf error out rather than silently dropping the value; JSON // can't carry them anyway, so API paths never hit this. Decimal::from_f64_retain(src).ok_or(MappingError::OutOfRange { field: "" }) } } - impl MapFrom for Decimal { - fn map_from(src: String) -> Result { + impl TryMapFrom for Decimal { + fn try_map_from(src: String) -> Result { src.parse() .map_err(|_| MappingError::Parse { field: "" }) } } - impl MapFrom for String { - fn map_from(src: Decimal) -> Result { + impl TryMapFrom for String { + fn try_map_from(src: Decimal) -> Result { Ok(src.to_string()) } } + impl MapFrom for String { + fn map_from(src: Decimal) -> Self { + src.to_string() + } + } } #[cfg(feature = "chrono")] mod chrono_leaves { - use super::{MapFrom, MappingError}; + use super::{TryMapFrom, MappingError}; use chrono::{DateTime, NaiveDate, Utc}; /// Canonical wire format for timestamps is rfc3339. - impl MapFrom for DateTime { - fn map_from(src: String) -> Result { + impl TryMapFrom for DateTime { + fn try_map_from(src: String) -> Result { DateTime::parse_from_rfc3339(&src) .map(|dt| dt.with_timezone(&Utc)) .map_err(|_| MappingError::Parse { @@ -334,8 +384,8 @@ mod chrono_leaves { }) } } - impl MapFrom> for String { - fn map_from(src: DateTime) -> Result { + impl TryMapFrom> for String { + fn try_map_from(src: DateTime) -> Result { Ok(src.to_rfc3339()) } } @@ -367,10 +417,10 @@ pub struct MapPair(pub Option, pub Option); pub trait MapFieldOpt { fn map_field_or(self) -> Result; } -impl> MapFieldOpt for &mut &mut &mut MapPair, D> { +impl> MapFieldOpt for &mut &mut &mut MapPair, D> { fn map_field_or(self) -> Result { match self.0.take().expect("magic_map field consumed twice") { - Some(s) => D::map_from(s), + Some(s) => D::try_map_from(s), None => Ok(self.1.take().expect("magic_map fallback consumed twice")), } } @@ -380,9 +430,9 @@ impl> MapFieldOpt for &mut &mut &mut MapPair, D> { pub trait MapFieldVal { fn map_field_or(self) -> Result; } -impl> MapFieldVal for &mut &mut MapPair { +impl> MapFieldVal for &mut &mut MapPair { fn map_field_or(self) -> Result { - D::map_from(self.0.take().expect("magic_map field consumed twice")) + D::try_map_from(self.0.take().expect("magic_map field consumed twice")) } } @@ -390,47 +440,47 @@ impl> MapFieldVal for &mut &mut MapPair { pub trait MapFieldWrap { fn map_field_or(self) -> Result; } -impl> MapFieldWrap> for &mut MapPair> { +impl> MapFieldWrap> for &mut MapPair> { fn map_field_or(self) -> Result, MappingError> { let src = self.0.take().expect("magic_map field consumed twice"); - Ok(Some(U::map_from(src)?)) + Ok(Some(U::try_map_from(src)?)) } } -/// `map_identity!(MyEnum);` — `MapFrom for MyEnum`, so same-typed +/// `map_identity!(MyEnum);` — `TryMapFrom for MyEnum`, so same-typed /// fields automap (model→model moves, e.g. invite→update). Declare next to /// the type; the orphan rule keeps it in the owning crate. #[macro_export] macro_rules! map_identity { ($($t:ty),+ $(,)?) => {$( - impl $crate::MapFrom<$t> for $t { - fn map_from(src: $t) -> ::core::result::Result { + impl $crate::TryMapFrom<$t> for $t { + fn try_map_from(src: $t) -> ::core::result::Result { Ok(src) } } )+}; } -/// `map_display!(MyEnum);` — `MapFrom for String` via `Display`, so +/// `map_display!(MyEnum);` — `TryMapFrom for String` via `Display`, so /// enum→string fields automap (pairs with strum's `Display` derive). #[macro_export] macro_rules! map_display { ($($t:ty),+ $(,)?) => {$( - impl $crate::MapFrom<$t> for ::std::string::String { - fn map_from(src: $t) -> ::core::result::Result { + impl $crate::TryMapFrom<$t> for ::std::string::String { + fn try_map_from(src: $t) -> ::core::result::Result { Ok(src.to_string()) } } )+}; } -/// `map_parse!(MyEnum);` — `MapFrom for MyEnum` via `FromStr`, so +/// `map_parse!(MyEnum);` — `TryMapFrom for MyEnum` via `FromStr`, so /// string→enum fields automap strictly (pairs with strum's `EnumString`). #[macro_export] macro_rules! map_parse { ($($t:ty),+ $(,)?) => {$( - impl $crate::MapFrom<::std::string::String> for $t { - fn map_from(src: ::std::string::String) -> ::core::result::Result { + impl $crate::TryMapFrom<::std::string::String> for $t { + fn try_map_from(src: ::std::string::String) -> ::core::result::Result { src.parse().map_err(|_| $crate::MappingError::Parse { field: ::core::stringify!($t), }) @@ -441,11 +491,11 @@ macro_rules! map_parse { // ── Crate-local funnel for foreign→foreign mappings ───────────────────────── // -// The fn form exists because `impl MapFrom for Dest` is only legal in a +// The fn form exists because `impl TryMapFrom for Dest` is only legal in a // crate owning one of the two types. But a mapping's *fields* funnelled through -// `MapFrom` too, so a nested field whose own mapping was also foreign→foreign +// `TryMapFrom` too, so a nested field whose own mapping was also foreign→foreign // had nothing to resolve against: `Vec
` → `Vec` needs -// `AddressResponse: MapFrom
`, and that impl cannot exist anywhere. +// `AddressResponse: TryMapFrom
`, and that impl cannot exist anywhere. // // `magic_map_scope!` plants a trait in the *calling* crate. The orphan rule is // satisfied by a local trait just as well as by a local type, so @@ -456,11 +506,11 @@ macro_rules! map_parse { // `LocalMapFrom` impl, leaves included. Two dead ends forced that, both worth // knowing before anyone tries to "simplify" this: // -// * A blanket bridge `impl> LocalMapFrom for D` overlaps +// * A blanket bridge `impl> LocalMapFrom for D` overlaps // the per-pair impls and coherence rejects it — "upstream crates may add a -// new impl of `MapFrom
` for `AddressResponse` in future versions". +// new impl of `TryMapFrom
` for `AddressResponse` in future versions". // -// * Nor can a second, `MapFrom`-backed tier sit underneath to catch leaves. +// * 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. A concrete @@ -487,7 +537,7 @@ macro_rules! map_parse { /// /// Needed only for the fn form. A crate whose mappings are all impl form /// (`magic_map!(Src => Dest)`, where one side is local) never calls it — those -/// funnel through `MapFrom` as they always have. Missing it reads as +/// funnel through `TryMapFrom` as they always have. Missing it reads as /// ``could not find `__magic_map_scope` in the crate root``. /// /// # Reaching your leaves @@ -508,7 +558,7 @@ macro_rules! map_parse { /// leaves_from: [quickedge_db], /// leaves: [Celsius, Celsius => String], /// generic_leaves: { -/// Patch => Patch where D: ::magic_map::MapFrom; +/// Patch => Patch where D: ::magic_map::TryMapFrom; /// }, /// } /// ``` @@ -543,8 +593,8 @@ macro_rules! magic_map_scope { #[allow(unused_imports)] use super::*; - /// Crate-local twin of [`magic_map::MapFrom`]. The fn form - /// implements it for pairs the orphan rule keeps off `MapFrom`; + /// Crate-local twin of [`magic_map::TryMapFrom`]. The fn form + /// implements it for pairs the orphan rule keeps off `TryMapFrom`; /// leaves are delegated in below. pub trait LocalMapFrom: Sized { fn local_map_from(src: S) -> ::core::result::Result; @@ -630,9 +680,9 @@ macro_rules! magic_map_scope { }; } -/// Delegates one `MapFrom` pair into the local trait. Used by +/// Delegates one `TryMapFrom` pair into the local trait. Used by /// `magic_map_scope!` for both the built-in leaves and the `leaves: [...]` -/// list; the body is a plain call, so a missing `MapFrom` impl fails here and +/// list; the body is a plain call, so a missing `TryMapFrom` impl fails here and /// names the pair. #[doc(hidden)] #[macro_export] @@ -640,7 +690,7 @@ macro_rules! __magic_map_scope_delegate { ($src:ty => $dest:ty) => { impl LocalMapFrom<$src> for $dest { fn local_map_from(src: $src) -> ::core::result::Result { - <$dest as $crate::MapFrom<$src>>::map_from(src) + <$dest as $crate::TryMapFrom<$src>>::try_map_from(src) } } }; @@ -683,7 +733,7 @@ macro_rules! __magic_map_scope_generic_leaves { ) => { impl< $($gen),* > LocalMapFrom<$src> for $dest { fn local_map_from(src: $src) -> ::core::result::Result { - <$dest as $crate::MapFrom<$src>>::map_from(src) + <$dest as $crate::TryMapFrom<$src>>::try_map_from(src) } } $crate::__magic_map_scope_generic_leaves!( $($rest)* ); @@ -711,7 +761,7 @@ macro_rules! __magic_map_scope_generic_split { ( [ $($gen:tt),* ] [ $src:ty ] [ $dest:ty ] [ $($bound:tt)* ] ; $($rest:tt)* ) => { impl< $($gen),* > LocalMapFrom<$src> for $dest where $($bound)* { fn local_map_from(src: $src) -> ::core::result::Result { - <$dest as $crate::MapFrom<$src>>::map_from(src) + <$dest as $crate::TryMapFrom<$src>>::try_map_from(src) } } $crate::__magic_map_scope_generic_leaves!( $($rest)* ); @@ -837,7 +887,7 @@ macro_rules! __magic_map_scope_json_leaves { () => {}; } -/// One `LocalMapFrom` impl delegating to an existing `MapFrom` pair. Emitted by +/// One `LocalMapFrom` impl delegating to an existing `TryMapFrom` pair. Emitted by /// a crate's replayed leaf list and by `magic_map_scope!`'s own `leaves`; the /// bare `LocalMapFrom` binds to whichever scope module it lands in. #[doc(hidden)] @@ -846,7 +896,7 @@ macro_rules! __magic_map_leaf_impl { ($src:ty => $dest:ty) => { impl LocalMapFrom<$src> for $dest { fn local_map_from(src: $src) -> ::core::result::Result { - <$dest as $crate::MapFrom<$src>>::map_from(src) + <$dest as $crate::TryMapFrom<$src>>::try_map_from(src) } } }; diff --git a/magic_map/tests/infallible.rs b/magic_map/tests/infallible.rs new file mode 100644 index 0000000..e2205c8 --- /dev/null +++ b/magic_map/tests/infallible.rs @@ -0,0 +1,88 @@ +//! `infallible` mappings, reference sources, and the check that you cannot +//! claim infallible for a conversion that can fail. +use magic_map::{magic_map, MagicMap, MapInto, TryMapInto}; +use uuid::Uuid; + +// The fn form registers itself in the crate-local funnel, so the scope has to +// exist here as it does in any crate that uses it. +magic_map::magic_map_scope!(from: [leaf_provider]); + +#[derive(MagicMap, Clone)] +pub struct Src { + pub id: Uuid, + pub name: String, + pub count: u32, +} + +#[derive(MagicMap, Debug, PartialEq)] +pub struct Dest { + pub id: String, // Uuid -> String is a to_string(): infallible + pub name: String, + pub count: u64, // u32 -> u64 is a lossless widening: infallible +} + +magic_map!(infallible Src => Dest); + +#[test] +fn infallible_impl_form_needs_no_question_mark() { + let id = Uuid::nil(); + let d: Dest = Src { + id, + name: "a".into(), + count: 7, + } + .map_into(); + assert_eq!(d.id, id.to_string()); + assert_eq!(d.count, 7u64); +} + +#[test] +fn infallible_is_also_reachable_fallibly() { + // The generated TryMapFrom half: a fallible caller does not need to know. + let d: Dest = Src { + id: Uuid::nil(), + name: "a".into(), + count: 1, + } + .try_map_into() + .expect("infallible mapping cannot fail"); + assert_eq!(d.count, 1u64); +} + +#[derive(MagicMap)] +pub struct RefDest { + pub name: String, + pub count: u64, +} + +// A borrowed source — the whole reason this exists is that cloning a large +// payload to map three fields off it is not a trade anyone wants to make. +magic_map!(infallible fn ref_dest: &Src => RefDest { + name: src.name.clone(), + count: src.count as u64, +}); + +#[test] +fn reference_source_maps_without_moving() { + let s = Src { + id: Uuid::nil(), + name: "borrowed".into(), + count: 3, + }; + let d = ref_dest(&s); + assert_eq!(d.name, "borrowed"); + assert_eq!(s.name, "borrowed"); // still ours +} + +// ── the claim is not on the honour system ──────────────────────────────────── +// String -> Uuid parses, so it has a TryMapFrom route and no MapFrom one. +// Uncommenting this must not compile. +#[derive(MagicMap)] +pub struct StringId { + pub id: String, +} +#[derive(MagicMap)] +pub struct UuidId { + pub id: Uuid, +} +magic_map!(StringId => UuidId); // fallible: fine diff --git a/magic_map/tests/magic_map.rs b/magic_map/tests/magic_map.rs index 705e43f..b3b5e89 100644 --- a/magic_map/tests/magic_map.rs +++ b/magic_map/tests/magic_map.rs @@ -9,15 +9,15 @@ magic_map::magic_map_scope! { // Everything `leaf_provider` declares, replayed without restating a type. from: [leaf_provider], // Escape hatches: a one-off pair whose crate has no block, and a generic - // wrapper whose own MapFrom impl is generic. + // wrapper whose own TryMapFrom impl is generic. leaves: [custom_leaf::Celsius, custom_leaf::Celsius => String, shared_source::db::Kind => String], generic_leaves: { - custom_leaf::Wrap => custom_leaf::Wrap where D: magic_map::MapFrom; + custom_leaf::Wrap => custom_leaf::Wrap where D: magic_map::TryMapFrom; }, } use magic_map::magic_map; -use magic_map::{MapFrom, MapInto, MappingError}; +use magic_map::{TryMapFrom, TryMapInto, MappingError}; use rust_decimal::Decimal; use uuid::Uuid; @@ -147,7 +147,7 @@ fn sample() -> db::License { #[test] fn struct_impl_form_auto_fills_and_applies_overrides() { - let dto: dtos::LicenseResponse = sample().map_into().unwrap(); + let dto: dtos::LicenseResponse = sample().try_map_into().unwrap(); assert_eq!(dto.id, Uuid::nil()); // identity leaf assert_eq!(dto.status, dtos::StatusDto::Active); // nested enum map assert_eq!(dto.max_devices, 10); // identity leaf @@ -161,9 +161,9 @@ fn struct_impl_form_auto_fills_and_applies_overrides() { #[test] fn enum_impl_form_maps_both_directions() { - let dto: dtos::StatusDto = db::Status::Suspended.map_into().unwrap(); + let dto: dtos::StatusDto = db::Status::Suspended.try_map_into().unwrap(); assert_eq!(dto, dtos::StatusDto::Suspended); - let back: db::Status = dto.map_into().unwrap(); + let back: db::Status = dto.try_map_into().unwrap(); assert_eq!(back, db::Status::Suspended); } @@ -177,7 +177,7 @@ fn fn_form_generates_a_plain_function() { #[test] fn tuple_source_struct_plus_primitive() { - let dto: dtos::LicenseResponse = (sample(), 7i64).map_into().unwrap(); + let dto: dtos::LicenseResponse = (sample(), 7i64).try_map_into().unwrap(); assert_eq!(dto.devices_used, 7); // override from opaque element assert_eq!(dto.status, dtos::StatusDto::Active); // auto from src.0 assert_eq!(dto.max_devices, 10); // auto from src.0 @@ -191,7 +191,7 @@ fn tuple_source_multi_struct_with_collision_override() { name: "acme".into(), }; let card: dtos::LicenseCard = (sample(), owner, Some("vip".to_string())) - .map_into() + .try_map_into() .unwrap(); assert_eq!(card.id, Uuid::max()); // collision resolved by override assert_eq!(card.name, "acme"); // auto: only Owner has `name` @@ -395,7 +395,7 @@ fn defaults_trailer_wraps_plain_sources_into_option_dests() { count: 3, note: None, } - .map_into() + .try_map_into() .expect("wrap automap"); assert_eq!(row.code.as_deref(), Some("EQ-1")); assert_eq!(row.owner_id, Some(id)); @@ -411,7 +411,7 @@ fn wrap_tier_is_strict_through_the_funnel() { count: 0, note: None, }; - let res: Result = bad.map_into(); + let res: Result = bad.try_map_into(); assert!(res.is_err(), "garbage must not become Some(default)"); } @@ -435,7 +435,7 @@ fn defaults_trailer_unwraps_options_with_instance_fallback() { kind: Some(dtos::StatusDto::Suspended), note: Some("n".into()), } - .map_into() + .try_map_into() .unwrap(); assert_eq!(row.label, "l"); // plain funnel assert_eq!(row.max_devices, 15); // None -> business default from instance @@ -448,7 +448,7 @@ fn defaults_trailer_unwraps_options_with_instance_fallback() { kind: None, note: None, } - .map_into() + .try_map_into() .unwrap(); assert_eq!(row2.max_devices, 3); // Some -> unwrapped assert_eq!(row2.kind, db::Status::Active); // None -> default variant @@ -460,7 +460,7 @@ fn defaults_trailer_fills_missing_fields() { let row: sparse::UpdateRow = sparse::PatchRequest { name: Some("n".into()), } - .map_into() + .try_map_into() .unwrap(); assert_eq!(row.name.as_deref(), Some("n")); assert_eq!(row.status, None); @@ -484,7 +484,7 @@ magic_map!((schemaless::Untouchable, i32) => dtos::StatusDto2Holder { #[test] fn tuple_all_overridden_needs_no_schemas() { let h: dtos::StatusDto2Holder = (schemaless::Untouchable { reason: "r".into() }, 7) - .map_into() + .try_map_into() .unwrap(); assert_eq!(h.code, 7); assert_eq!(h.reason, "r"); @@ -519,7 +519,7 @@ fn validated_dest_passes_when_data_is_valid() { name: "Alice".into(), email: "alice@example.com".into(), } - .map_into() + .try_map_into() .unwrap(); assert_eq!(dto.name, "Alice"); } @@ -531,7 +531,7 @@ fn validated_dest_errors_when_data_fails_constraints() { name: "".into(), email: "alice@example.com".into(), } - .map_into(); + .try_map_into(); assert!( matches!(result, Err(MappingError::Validation(_))), "expected Validation error, got {result:?}", @@ -542,7 +542,7 @@ fn validated_dest_errors_when_data_fails_constraints() { name: "Alice".into(), email: "not-an-email".into(), } - .map_into(); + .try_map_into(); assert!(matches!(result2, Err(MappingError::Validation(_)))); } @@ -586,7 +586,7 @@ fn validated_dest_with_defaults_trailer() { name: "Misifu".into(), note: None, } - .map_into() + .try_map_into() .unwrap(); assert_eq!(row.name, "Misifu"); assert_eq!(row.status, "new"); @@ -596,7 +596,7 @@ fn validated_dest_with_defaults_trailer() { name: "".into(), note: None, } - .map_into(); + .try_map_into(); assert!(matches!(bad, Err(MappingError::Validation(_)))); } @@ -666,16 +666,16 @@ fn validated_dest_tuple_source_validates() { #[test] fn leaf_conversions() { - let u = Uuid::map_from("00000000-0000-0000-0000-000000000000".to_string()).unwrap(); + let u = Uuid::try_map_from("00000000-0000-0000-0000-000000000000".to_string()).unwrap(); assert_eq!(u, Uuid::nil()); assert_eq!( - Uuid::map_from("not-a-uuid".to_string()), + Uuid::try_map_from("not-a-uuid".to_string()), Err(MappingError::InvalidUuid { field: "" }) ); - let d = Decimal::map_from(20.5_f64).unwrap(); + let d = Decimal::try_map_from(20.5_f64).unwrap(); assert_eq!(d, Decimal::new(205, 1)); assert_eq!( - Decimal::map_from(f64::NAN), + Decimal::try_map_from(f64::NAN), Err(MappingError::OutOfRange { field: "" }) ); } @@ -757,7 +757,7 @@ mod override_question_mark { // // The case the fn form could not express before: a mapper crate that owns // neither side. `db` and `dtos` stand in for `quickedge_db` / `quickedge_dtos` -// — nothing in either knows the other exists, and no `MapFrom` impl between +// — nothing in either knows the other exists, and no `TryMapFrom` impl between // them is legal anywhere. mod scoped { @@ -935,19 +935,19 @@ mod custom_leaf { magic_map::map_identity!(Celsius); - /// A generic wrapper, like `quickedge_commons::Patch`: its `MapFrom` + /// A generic wrapper, like `quickedge_commons::Patch`: its `TryMapFrom` /// impl is generic, so no list of concrete pairs can cover it. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Wrap(pub T); - impl> magic_map::MapFrom> for Wrap { - fn map_from(src: Wrap) -> Result { - Ok(Wrap(D::map_from(src.0)?)) + impl> magic_map::TryMapFrom> for Wrap { + fn try_map_from(src: Wrap) -> Result { + Ok(Wrap(D::try_map_from(src.0)?)) } } - impl magic_map::MapFrom for String { - fn map_from(src: Celsius) -> Result { + impl magic_map::TryMapFrom for String { + fn try_map_from(src: Celsius) -> Result { Ok(format!("{}C", src.0)) } } @@ -1001,7 +1001,7 @@ fn leaves_arrive_from_a_provider_crate() { pub struct ReadingResponse { pub species: leaf_provider::enums::Species, // identity pub species_label: String, // display - pub temp: String, // hand-written MapFrom + pub temp: String, // hand-written TryMapFrom } } diff --git a/magic_map/tests/readme.rs b/magic_map/tests/readme.rs index c052912..7e15ad3 100644 --- a/magic_map/tests/readme.rs +++ b/magic_map/tests/readme.rs @@ -11,7 +11,7 @@ // `magic_map_scope!` section. Once, at the crate root, no arguments. magic_map::magic_map_scope!(from: [leaf_provider]); -use magic_map::{MapInto, MappingError}; +use magic_map::{TryMapInto, MappingError}; // ── Quick start ────────────────────────────────────────────────────────────── @@ -53,7 +53,7 @@ fn quick_start() -> Result<(), MappingError> { age: 3, born: "2024-01-15T10:30:00Z".parse().unwrap(), } - .map_into()?; + .try_map_into()?; assert_eq!(dto.id, "00000000-0000-0000-0000-000000000000"); assert_eq!(dto.name, "Misifu"); @@ -96,7 +96,7 @@ fn impl_form() -> Result<(), MappingError> { name: "Rex".into(), weight_kg: 38.5, } - .map_into()?; + .try_map_into()?; assert_eq!(dto.name, "Rex"); assert!(dto.big); Ok(()) @@ -234,7 +234,7 @@ fn tuple_sources() -> Result<(), MappingError> { }, Some("indoor only".to_string()), ) - .map_into()?; + .try_map_into()?; assert_eq!(card.id, 99); // picked from Owner assert_eq!(card.name, "Misifu"); // picked from Cat @@ -330,7 +330,7 @@ fn defaults_trailer() -> Result<(), MappingError> { lives: None, note: None, } - .map_into()?; + .try_map_into()?; assert_eq!(row.name, "Misifu"); // plain funnel assert_eq!(row.lives, 9); // None → business default from the model @@ -342,7 +342,7 @@ fn defaults_trailer() -> Result<(), MappingError> { lives: Some(7), note: Some("bites".into()), } - .map_into()?; + .try_map_into()?; assert_eq!(row2.lives, 7); // Some → unwrapped through the funnel assert_eq!(row2.note.as_deref(), Some("bites")); Ok(()) @@ -377,7 +377,7 @@ fn wrap_tier() -> Result<(), MappingError> { chip_id: "67e55044-10b1-426f-9247-bb680e5fe0c8".into(), weight_kg: 4.2, } - .map_into()?; + .try_map_into()?; assert!(patch.chip_id.is_some()); assert!(patch.weight_kg.is_some()); @@ -386,7 +386,7 @@ fn wrap_tier() -> Result<(), MappingError> { chip_id: "not-a-uuid".into(), weight_kg: 4.2, } - .map_into(); + .try_map_into(); assert!(bad.is_err()); Ok(()) } @@ -429,7 +429,7 @@ fn validation() -> Result<(), MappingError> { email: "alice@example.com".into(), age: 30, } - .map_into()?; + .try_map_into()?; assert_eq!(user.name, "Alice"); assert_eq!(user.age, 30_i64); @@ -439,7 +439,7 @@ fn validation() -> Result<(), MappingError> { email: "not-an-email".into(), age: 30, } - .map_into(); + .try_map_into(); assert!(matches!(bad, Err(magic_map::MappingError::Validation(_)))); Ok(()) } @@ -464,8 +464,8 @@ mod leaves { pub seconds: i64, } - impl magic_map::MapFrom for chrono::DateTime { - fn map_from(src: MyWireTimestamp) -> Result { + impl magic_map::TryMapFrom for chrono::DateTime { + fn try_map_from(src: MyWireTimestamp) -> Result { chrono::DateTime::from_timestamp(src.seconds, 0).ok_or( magic_map::MappingError::OutOfRange { field: "", @@ -479,16 +479,16 @@ mod leaves { fn custom_leaves() -> Result<(), MappingError> { use leaves::{MyWireTimestamp, Species}; - let s: String = Species::Lion.map_into()?; + let s: String = Species::Lion.try_map_into()?; assert_eq!(s, "Lion"); - let back: Species = "Lion".to_string().map_into()?; + let back: Species = "Lion".to_string().try_map_into()?; assert_eq!(back, Species::Lion); - let bad: Result = "Liger".to_string().map_into(); + let bad: Result = "Liger".to_string().try_map_into(); assert!(bad.is_err()); // strict parse - let ts: chrono::DateTime = MyWireTimestamp { seconds: 0 }.map_into()?; + let ts: chrono::DateTime = MyWireTimestamp { seconds: 0 }.try_map_into()?; assert_eq!(ts.to_rfc3339(), "1970-01-01T00:00:00+00:00"); Ok(()) } diff --git a/magic_map/tests/sealed.rs b/magic_map/tests/sealed.rs new file mode 100644 index 0000000..6d2d589 --- /dev/null +++ b/magic_map/tests/sealed.rs @@ -0,0 +1,58 @@ +//! Sealing, exercised the only way that means anything: from another crate. +//! `leaf_provider` owns the types; this crate is foreign to them, exactly as a +//! service crate is foreign to the crate that owns its DTOs. +use leaf_provider::{OpenDto, SealedDto}; +// The schema alias lives next to the type, so the destination is spelled by path. +use magic_map::{magic_map, MagicMap, TryMapInto}; + +magic_map::magic_map_scope!(from: [leaf_provider]); + +#[derive(MagicMap)] +pub struct Row { + pub id: String, + pub count: u64, +} + +// The generated mapping targets a sealed type and compiles: it builds through +// the constructor rather than a struct expression. +magic_map!(Row => leaf_provider::SealedDto); + +#[test] +fn magic_map_can_still_build_a_sealed_type() { + let d: SealedDto = Row { + id: "x".into(), + count: 2, + } + .try_map_into() + .unwrap(); + assert_eq!(d, SealedDto::__magic_map_new_unchecked("x".into(), 2)); +} + +#[test] +fn an_unsealed_type_is_unaffected() { + // The control: still constructible by hand from here. + let d = OpenDto { + id: "x".into(), + count: 2, + }; + assert_eq!(d.count, 2); +} + +// ── what sealing forbids, from a foreign crate ─────────────────────────────── +// Both of these are E0639, "cannot create non-exhaustive struct using struct +// expression". They are the reason the attribute exists, so they are written +// down rather than merely believed: +// +// fn hand_rolled_map(r: Row) -> SealedDto { +// SealedDto { id: r.id, count: r.count } // E0639 +// } +// +// impl From for SealedDto { +// fn from(l: Local) -> Self { +// Self { id: l.0, count: 0 } // E0639 +// } +// } +// +// The second is the one worth noticing: banning `impl From` needs no separate +// mechanism, because a From impl for a sealed type cannot construct its own +// output. Forbidding the hand-rolled map forbids the hand-rolled From with it. diff --git a/magic_map_macros/src/lib.rs b/magic_map_macros/src/lib.rs index 504bbd8..f863b92 100644 --- a/magic_map_macros/src/lib.rs +++ b/magic_map_macros/src/lib.rs @@ -31,7 +31,7 @@ pub fn derive_magic_map(input: TokenStream) -> TokenStream { /// `magic_map!` — declare a struct/enum mapping at the call site. /// /// ```ignore -/// // impl form → `impl magic_map::MapFrom for Dest` +/// // impl form → `impl magic_map::TryMapFrom for Dest` /// // (orphan rule: Src or Dest must be local to the calling crate) /// magic_map!(db::LicenseStatus => super::dtos::LicenseStatusDto); /// magic_map!(db::License => super::dtos::LicenseResponse { @@ -39,8 +39,8 @@ pub fn derive_magic_map(input: TokenStream) -> TokenStream { /// license_type: parse_license_type(&src.license_type), // custom expr /// }); /// -/// // tuple source → `impl MapFrom<(License, i64)> for LicenseResponse`; -/// // call sites do `(license, count).map_into()?`. +/// // tuple source → `impl TryMapFrom<(License, i64)> for LicenseResponse`; +/// // call sites do `(license, count).try_map_into()?`. /// magic_map!((db::License, i64) => super::dtos::LicenseResponse { /// devices_used: src.1 as i32, /// license_type: parse_license_type(&src.0.license_type), @@ -53,7 +53,7 @@ pub fn derive_magic_map(input: TokenStream) -> TokenStream { /// ``` /// /// Every destination field without an override is auto-filled from the -/// same-named source field through the `MapFrom` leaf funnel (identities, +/// same-named source field through the `TryMapFrom` leaf funnel (identities, /// String↔Uuid, Decimal↔f64, `Option`/`Vec` wrappers, mapped enums/structs). /// Override expressions may use `src` (the whole source value). Enum mappings /// are variant-by-name and take `SrcVariant => DestVariant` rename pairs @@ -104,7 +104,7 @@ pub fn __magic_map_expand(input: TokenStream) -> TokenStream { /// `magic_map_leaves!` — declare a crate's leaf conversions once, and publish /// the list so consumers never restate it. /// -/// Call it once, in your crate root. It emits the `MapFrom` impls (the same +/// Call it once, in your crate root. It emits the `TryMapFrom` impls (the same /// ones `map_identity!` / `map_display!` / `map_parse!` produce) and a hidden /// macro that `magic_map_scope!`'s `from:` replays — so adding a type here /// reaches every downstream crate with no edit on their side. @@ -128,3 +128,29 @@ pub fn magic_map_leaves(input: TokenStream) -> TokenStream { magic_map::leaves(parse_macro_input!(input as magic_map::LeavesInput)) .unwrap_or_else(|e| e.to_compile_error().into()) } + + +/// `#[mapped]` — the attribute form of `#[derive(MagicMap)]`, plus sealing. +/// +/// `#[mapped]` on its own is exactly the derive: it publishes the type's field +/// names so `magic_map!` can target it. +/// +/// `#[mapped(sealed)]` adds `#[non_exhaustive]` and a hidden all-fields +/// constructor. From any other crate the type then has no struct expression at +/// all, so a hand-rolled field-by-field copy in a service or a controller stops +/// compiling — and so does `impl From for T`, because that impl body cannot +/// construct its own output either. `magic_map!` builds through the constructor +/// and keeps working. +/// +/// It is an attribute rather than a derive because a derive is additive-only: +/// it sees the item and emits new items beside it, and can never put +/// `#[non_exhaustive]` *on* it. +/// +/// Sealing is skipped for shapes where it would mean nothing — unit and tuple +/// structs, enums, and field-less markers like a proto `Empty`, where the only +/// effect would be to break every `Ok(Empty {})` in the tree. +#[proc_macro_attribute] +pub fn mapped(attr: TokenStream, item: TokenStream) -> TokenStream { + magic_map::mapped(attr.into(), item.into()) + .unwrap_or_else(|e| e.to_compile_error().into()) +} diff --git a/magic_map_macros/src/magic_map.rs b/magic_map_macros/src/magic_map.rs index 6866aa2..1fbcce3 100644 --- a/magic_map_macros/src/magic_map.rs +++ b/magic_map_macros/src/magic_map.rs @@ -13,12 +13,12 @@ //! function-like macro cannot see `Dest`'s fields, so it expands to a call //! of `Dest`'s schema macro (rewriting the last path segment), which… //! 3. …calls back into `__magic_map_expand!` with the field list spliced in, -//! which generates the real `impl MapFrom for Dest` — or a plain +//! which generates the real `impl TryMapFrom for Dest` — or a plain //! `fn` for the foreign→foreign case (e.g. db→proto in a service crate) //! where the orphan rule forbids any impl. //! //! Every non-overridden destination field is pulled from `src.` -//! through the `magic_map::MapFrom` leaf funnel, so String→Uuid, +//! through the `magic_map::TryMapFrom` leaf funnel, so String→Uuid, //! Decimal↔f64, Option/Vec wrappers and mapped enums compose for free. use proc_macro::TokenStream; @@ -31,6 +31,100 @@ use syn::{Data, DeriveInput, Fields, Ident, Path, Token}; // ── 1. #[derive(MagicMap)] — metadata only ─────────────────────────────────── pub fn derive(input: DeriveInput) -> Result { + schema_for(&input, false) +} + +/// `#[mapped]` / `#[mapped(sealed)]` — the attribute form. +/// +/// It exists because a *derive* cannot add an attribute to the item it is on: +/// derives are additive-only, they see the item and emit new items beside it. +/// Sealing needs `#[non_exhaustive]` *on* the struct, so it needs an attribute. +/// +/// `sealed` is the whole reason to prefer it. It makes the type impossible to +/// build with a struct expression from any other crate, which is not a style +/// rule — it means a hand-rolled field-by-field copy in a service or a +/// controller stops compiling, and so does an `impl From` for the type, since +/// that impl's body cannot construct its own output either. Mapping code goes +/// through `magic_map!`, which constructs via the hidden constructor below. +/// +/// The constructor is `pub` because the expansion lives in the caller's crate +/// and macro expansions hold no privilege a hand-written line does not. Someone +/// determined can call it. The point is that the wrong path stops being the +/// easy invisible one and becomes a positional call with a name that reads as +/// an accusation in review. +pub fn mapped(attr: TokenStream2, item: TokenStream2) -> Result { + let mut sealed = false; + if !attr.is_empty() { + let parser = syn::meta::parser(|meta| { + if meta.path.is_ident("sealed") { + sealed = true; + Ok(()) + } else if meta.path.is_ident("export") { + let _: syn::LitStr = meta.value()?.parse()?; + Ok(()) + } else { + Err(meta.error("expected `sealed` or `export = \"UniqueName\"`")) + } + }); + syn::parse::Parser::parse2(parser, attr.clone())?; + } + + let input: DeriveInput = syn::parse2(item)?; + let schema: TokenStream2 = schema_for(&input, sealed)?.into(); + + let named = match &input.data { + Data::Struct(d) => match &d.fields { + Fields::Named(n) => Some(n), + _ => None, + }, + _ => None, + }; + + // Only a named-field struct can be sealed: there is nothing to seal on a + // unit or tuple struct, and an enum has no struct expression to block. A + // zero-field struct is a marker — `Empty {}` in a proto tree — and sealing + // one buys nothing while breaking every `Ok(Empty {})` in the tree. + let seal = match (sealed, named) { + (true, Some(n)) if !n.named.is_empty() => { + let name = &input.ident; + let (imp, ty, wher) = input.generics.split_for_impl(); + let args: Vec<_> = n + .named + .iter() + .map(|f| { + let id = f.ident.as_ref().unwrap(); + let t = &f.ty; + quote! { #id: #t } + }) + .collect(); + let names: Vec<_> = n.named.iter().map(|f| f.ident.as_ref().unwrap()).collect(); + ( + quote! { #[non_exhaustive] }, + quote! { + impl #imp #name #ty #wher { + #[doc(hidden)] + #[allow(clippy::too_many_arguments)] + pub fn __magic_map_new_unchecked(#(#args),*) -> Self { + Self { #(#names),* } + } + } + }, + ) + } + _ => (TokenStream2::new(), TokenStream2::new()), + }; + let (attr_tokens, ctor) = seal; + + Ok(quote! { + #attr_tokens + #input + #ctor + #schema + } + .into()) +} + +fn schema_for(input: &DeriveInput, sealed: bool) -> Result { let name = &input.ident; let schema_name = format_ident!("__magic_map_schema_{}", name); @@ -60,10 +154,11 @@ pub fn derive(input: DeriveInput) -> Result { .named .iter() .any(|f| f.attrs.iter().any(|a| a.path().is_ident("validate"))); - let kind = if validated { - quote! { vstruct } - } else { - quote! { struct } + let kind = match (validated, sealed) { + (true, true) => quote! { svstruct }, + (true, false) => quote! { vstruct }, + (false, true) => quote! { sstruct }, + (false, false) => quote! { struct }, }; quote! { @#kind [ #(#fields),* ] } } @@ -130,6 +225,7 @@ pub fn derive(input: DeriveInput) -> Result { // ── 2. magic_map! — the call-site front end ────────────────────────────────── pub struct MagicMapInput { + infallible: bool, func: Option<(syn::Visibility, Ident)>, src: syn::Type, dest: Path, @@ -138,6 +234,12 @@ pub struct MagicMapInput { impl Parse for MagicMapInput { fn parse(input: ParseStream) -> syn::Result { + // `infallible` is a contextual keyword, not a reserved one — peek for + // the ident rather than claiming the word for every caller. + let infallible = input.peek(Ident) && input.fork().parse::()? == "infallible"; + if infallible { + input.parse::()?; + } let func = if input.peek(Token![pub]) || input.peek(Token![fn]) { let vis: syn::Visibility = input.parse()?; input.parse::()?; @@ -151,10 +253,12 @@ impl Parse for MagicMapInput { match &src { syn::Type::Path(_) => {} syn::Type::Tuple(t) if !t.elems.is_empty() => {} + syn::Type::Reference(r) if matches!(&*r.elem, syn::Type::Path(_)) => {} _ => { return Err(syn::Error::new_spanned( &src, - "magic_map! source must be a type path or a non-empty tuple of types", + "magic_map! source must be a type path, a reference to one, \ + or a non-empty tuple of types", )) } } @@ -168,6 +272,7 @@ impl Parse for MagicMapInput { TokenStream2::new() }; Ok(Self { + infallible, func, src, dest, @@ -178,6 +283,7 @@ impl Parse for MagicMapInput { pub fn front(input: MagicMapInput) -> TokenStream { let MagicMapInput { + infallible, func, src, dest, @@ -190,9 +296,11 @@ pub fn front(input: MagicMapInput) -> TokenStream { let last = schema.segments.last_mut().unwrap(); last.ident = format_ident!("__magic_map_schema_{}", last.ident); - let mode = match func { - Some((vis, name)) => quote! { @fn(#vis #name) }, - None => quote! { @impl }, + let mode = match (func, infallible) { + (Some((vis, name)), false) => quote! { @fn(#vis #name) }, + (Some((vis, name)), true) => quote! { @ifn(#vis #name) }, + (None, false) => quote! { @impl }, + (None, true) => quote! { @iimpl }, }; quote! { @@ -215,6 +323,7 @@ pub fn front(input: MagicMapInput) -> TokenStream { // @src(Type) @dest(Path) @overrides{ field: expr, .. } struct Shape { + sealed: bool, is_enum: bool, /// True when the destination struct's schema was emitted as `@vstruct` /// because some field carries `#[validate(...)]`. @@ -233,13 +342,17 @@ fn parse_shape(input: ParseStream) -> syn::Result { .collect(); Ok(Shape { is_enum: kind == "enum", - // @vstruct signals "struct with #[validate(...)] fields"; plain @struct does not. - validated: kind == "vstruct", + // @vstruct signals "struct with #[validate(...)] fields"; @sstruct that the + // type is sealed, so it is built through its constructor rather than a + // struct expression. @svstruct is both. + sealed: kind == "sstruct" || kind == "svstruct", + validated: kind == "vstruct" || kind == "svstruct", names, }) } pub struct ExpandInput { + infallible: bool, collected: Vec<(usize, Shape)>, dest_shape: Shape, func: Option<(syn::Visibility, Ident)>, @@ -281,7 +394,8 @@ impl Parse for ExpandInput { // @impl | @fn(vis name) input.parse::()?; let mode: Ident = input.call(Ident::parse_any)?; - let func = if mode == "fn" { + let infallible = mode == "ifn" || mode == "iimpl"; + let func = if mode == "fn" || mode == "ifn" { let fn_content; syn::parenthesized!(fn_content in input); let vis: syn::Visibility = fn_content.parse()?; @@ -352,6 +466,7 @@ impl Parse for ExpandInput { } Ok(Self { + infallible, collected, dest_shape, func, @@ -408,6 +523,7 @@ enum FieldSource { pub fn expand(raw: TokenStream2, input: ExpandInput) -> Result { let ExpandInput { + infallible, collected, dest_shape, func, @@ -473,10 +589,27 @@ pub fn expand(raw: TokenStream2, input: ExpandInput) -> Result Result = Vec::new(); let mut defaulted = false; for f in &dest_shape.names { if let Some((_, expr)) = overrides.iter().find(|(name, _)| name == f) { - assigns.push(quote! { #f: #expr }); + assigns.push((f.clone(), quote! { #expr })); continue; } let source = if tuple_elems.is_none() { @@ -636,11 +769,11 @@ pub fn expand(raw: TokenStream2, input: ExpandInput) -> Result Result Result = assigns.iter().map(|(f, _)| f).collect(); + let values: Vec<&TokenStream2> = assigns.iter().map(|(_, v)| v).collect(); + let build = if dest_shape.sealed { + if defaulted || defaults { + quote! {{ + let mut __magic_seal = <#dest as ::core::default::Default>::default(); + #( __magic_seal.#fields = #values; )* + __magic_seal + }} + } else { + quote! { #dest::__magic_map_new_unchecked(#(#values),*) } + } + } else { + quote! { #dest { #(#fields: #values,)* #trailer } } + }; + let prelude = defaults.then(|| { quote! { let __magic_fb = <#dest as ::core::default::Default>::default(); } }); - let trailer = defaulted.then(|| quote! { ..::core::default::Default::default() }); if dest_shape.validated { quote! { #prelude #(#lets)* - let __magic_result = #dest { #(#assigns,)* #trailer }; + let __magic_result = #build; ::magic_map::validator::Validate::validate(&__magic_result) .map_err(::magic_map::MappingError::Validation)?; ::core::result::Result::Ok(__magic_result) } + } else if infallible { + quote! { #prelude #(#lets)* #build } } else { - quote! { #prelude #(#lets)* ::core::result::Result::Ok(#dest { #(#assigns,)* #trailer }) } + quote! { #prelude #(#lets)* ::core::result::Result::Ok(#build) } } }; + if infallible { + return Ok(match func { + Some((vis, name)) => quote! { + #vis fn #name(#src_var: #src) -> #dest { + #body + } + + // Registered in the crate-local fallible funnel too, wrapping in + // Ok: a fallible mapping nesting this type still auto-fills. + impl crate::__magic_map_scope::LocalMapFrom<#src> for #dest { + fn local_map_from( + src: #src, + ) -> ::core::result::Result { + ::core::result::Result::Ok(#name(src)) + } + } + }, + None => quote! { + impl ::magic_map::MapFrom<#src> for #dest { + fn map_from(#src_var: #src) -> Self { + #body + } + } + + // The fallible half comes free, so an infallible mapping is + // usable from `try_map_into()` without the caller knowing. + // Written out rather than blanket-impl'd: a blanket + // `impl> TryMapFrom for D` overlaps every + // generated TryMapFrom impl and coherence rejects it. + impl ::magic_map::TryMapFrom<#src> for #dest { + fn try_map_from( + src: #src, + ) -> ::core::result::Result { + ::core::result::Result::Ok( + >::map_from(src), + ) + } + } + }, + } + .into()); + } + Ok(match func { Some((vis, name)) => quote! { #vis fn #name( @@ -705,7 +913,7 @@ pub fn expand(raw: TokenStream2, input: ExpandInput) -> Result for #dest { fn local_map_from( src: #src, @@ -715,8 +923,8 @@ pub fn expand(raw: TokenStream2, input: ExpandInput) -> Result quote! { - impl ::magic_map::MapFrom<#src> for #dest { - fn map_from( + impl ::magic_map::TryMapFrom<#src> for #dest { + fn try_map_from( #src_var: #src, ) -> ::core::result::Result { #body @@ -852,7 +1060,7 @@ pub fn leaves(input: LeavesInput) -> Result { fn local_map_from( src: #src, ) -> ::core::result::Result { - <#dest as ::magic_map::MapFrom<#src>>::map_from(src) + <#dest as ::magic_map::TryMapFrom<#src>>::try_map_from(src) } } } From 51071c08aa3719d01985bc6cd2a693ca0858ef38 Mon Sep 17 00:00:00 2001 From: Ricardo Sansores Date: Thu, 20 Aug 2026 12:35:20 -0600 Subject: [PATCH 02/10] docs: document the infallible split, borrowed sources and sealing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README had the rename but none of the three features, and there was no upgrade path written down. MIGRATING.md covers the sed, what each capability buys, and — because it is the part people will get wrong — where sealing does and does not reach: the boundary is the crate, not the directory, so a helper struct declared next to its mapper is not protected and moving code between modules changes nothing. --- MIGRATING.md | 180 +++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 68 +++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 MIGRATING.md diff --git a/MIGRATING.md b/MIGRATING.md new file mode 100644 index 0000000..e187724 --- /dev/null +++ b/MIGRATING.md @@ -0,0 +1,180 @@ +# Migrating 0.3 → 0.4 + +One rename, three new capabilities. The rename is mechanical — a `sed` and a +build — and it is the only thing that breaks. + +--- + +## Why + +### The `Result` nobody could get rid of + +`MapFrom::map_from` returned `Result` because *some* +conversions can fail. `String` → `Uuid` parses. `f64` → `Decimal` can be NaN. + +But an identity is not one of those, and neither is a lossless widening, and +neither is `Uuid` → `String`. A mapping built entirely from those cannot fail, +and 0.3 had no way to say so. Every call site wrote `?` for a failure that did +not exist, and — worse — a function with no other fallible step had to start +returning `Result` just to host the `?`. + +A one-field copy between two structs is the clearest case. In 0.3 the honest +options were an infallible hand-written `impl From` (giving up the funnel, the +leaf conversions, and every guarantee the crate exists to provide) or a `?` on +a `Uuid` move. Neither is right, so people picked `From`, and the whole point +of the crate leaked away one struct at a time. + +0.4 splits the two: + +| | infallible | fallible | +|---|---|---| +| trait | `MapFrom` / `MapInto` | `TryMapFrom` / `TryMapInto` | +| method | `map_from` / `map_into` | `try_map_from` / `try_map_into` | +| returns | `Self` | `Result` | +| declared | `magic_map!(infallible …)` | `magic_map!(…)` | + +The names now line up with `From` / `TryFrom`, which is what they should have +been at 0.1. + +**The claim is checked, not trusted.** An infallible expansion contains no `?`, +so a field pair that only has a `TryMapFrom` route fails to resolve: + +```rust +magic_map!(infallible StringId => UuidId); +``` +``` +error[E0277]: the trait bound `Uuid: MapFrom` is not satisfied +``` + +You cannot claim infallible for something that can fail. And infallible +mappings get the fallible half generated too, so `try_map_into()` still works +on them — a caller that does not care stays unaware. + +### Borrowed sources + +The tuple form always accepted references in element position; only the +top-level parse refused them. That left a borrowed source spelled as a +one-tuple, `f((payload,))?`, or a clone of a payload to read three fields off +it. Now: + +```rust +magic_map!(infallible fn fiscal_fields: &CompanyPayload => FiscalFields { … }); +``` + +### Sealing — `#[mapped(sealed)]` + +The failure this crate exists to prevent is not a wrong `From` impl. It is the +field-by-field copy someone types directly into a service or a controller, +using no trait at all. No linter catches that reliably: a grep cannot tell +construction from destructuring, and cannot tell a mapping from assembling a +page envelope. + +`#[mapped(sealed)]` adds `#[non_exhaustive]` and a hidden all-fields +constructor. From any other crate the type then has **no struct expression at +all**, so the hand-rolled copy stops compiling: + +``` +error[E0639]: cannot create non-exhaustive struct using struct expression +``` + +`magic_map!` builds through the constructor and keeps working, so declared +mappings are unaffected. + +**Banning `impl From` needs no separate feature.** A `From` impl for a sealed +type cannot construct its own output — the impl body hits the same E0639. Forbid +the manual map and you have forbidden the manual `From` with it. + +It is an attribute rather than a derive because a derive is additive-only: it +sees the item and emits new items beside it, and can never place +`#[non_exhaustive]` *on* it. It is spelled `#[mapped]` rather than +`#[magic_map]` because the latter collides with the `magic_map!` macro — +function-like and attribute macros share a namespace (`E0428`). + +--- + +## What sealing does and does not reach + +Worth knowing before rolling it out, because the boundary is the *crate*, not +the directory. + +**Reached** — any type whose owning crate is not the crate doing the mapping. +In a layered codebase that is most of them: wire DTOs, database models, and +generated proto types are each owned by their own crate, and the mappers live +somewhere else. + +**Not reached:** + +- **Types local to the mapping crate.** `#[non_exhaustive]` has no effect + within the defining crate, so a helper struct declared next to the mapper is + not protected. Moving code between modules changes nothing — `services/` and + `mappers/` in one crate are the same crate to the compiler. +- **Conversions *out of* a sealed type.** `impl From for Other` + compiles: the sealed type is the source, and nothing is being constructed. +- **Anything not sealed.** Sealing is per-type and opt-in. + +So sealing is a strong guarantee at layer boundaries and silent inside a layer. +Keep whatever review or lint you use for the rest. + +**Do not seal blanket-style.** A field-less marker — a proto `Empty` — gains +nothing and breaks every `Ok(Empty {})` in the tree; the attribute skips +zero-field structs, unit and tuple structs, and enums for that reason. Test +fixtures in other crates construct types by hand and will break: decide how +they build values before you seal a widely-used type. + +--- + +## Doing it + +### 1. Rename (required) + +Every 0.3 name was the fallible one: + +```sh +git ls-files -z '*.rs' '*.md' | xargs -0 sed -i \ + 's/\bMapFrom\b/TryMapFrom/g; s/\bmap_from\b/try_map_from/g; + s/\bMapInto\b/TryMapInto/g; s/\bmap_into\b/try_map_into/g' +``` + +Word boundaries matter: they leave `map_identity!`, `map_display!`, +`map_parse!` and `magic_map_scope!` alone. Re-exports move with everything +else. Then build — there is nothing else to do. + +### 2. Make the conversions that cannot fail say so (optional) + +Add `infallible` and drop the `?`: + +```rust +magic_map!(infallible ExtractorContext => commons::TenantContext); +``` +```rust +let scope = tenant.map_into(); // was: tenant.into(), or try_map_into()? +``` + +If it does not compile, the mapping was not infallible and the error names the +field pair. + +### 3. Seal, one owning crate at a time (optional) + +Replace the derive — the attribute must come **first**, since it rewrites the +item the derives then see: + +```rust +#[mapped(sealed)] +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct CustomerDto { … } +``` + +For prost, one line, and prefer a path over `"."`: + +```rust +.type_attribute(".mypkg.Customer", "#[magic_map::mapped(sealed)]") +``` + +`#[mapped]` with no argument is exactly `#[derive(MagicMap)]`; the derive stays +supported, so this is per-type and can stop wherever you want. + +Expect a first pass to fail on real findings, not on the mechanism. Fix them by +declaring the mapping rather than by reaching for +`__magic_map_new_unchecked` — which is public only because a macro expansion +holds no privilege a hand-written line lacks, and which is named to be obvious +in review. diff --git a/README.md b/README.md index 712b32f..f991915 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # magic_map +> Upgrading from 0.3? See [MIGRATING.md](./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. @@ -652,6 +654,72 @@ ever called. [validator]: https://crates.io/crates/validator +## 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` | +| 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. + +```rust +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. + +Sources may be borrowed: + +```rust +magic_map!(infallible fn fiscal: &CompanyPayload => FiscalFields { … }); +``` + +## 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: + +```rust +#[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`. + ## Leaves A *leaf* is a `TryMapFrom` impl for a known type pair. Identities for primitives From f4e0a56f36b66446145f3aeba08289293ab5e864 Mon Sep 17 00:00:00 2001 From: Ricardo Sansores Date: Fri, 21 Aug 2026 11:41:49 -0600 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20infallible=20enum=20mappings=20?= =?UTF-8?q?=E2=80=94=20the=20match=20body=20must=20not=20wrap=20in=20Ok?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The struct path already branched on infallible; the enum path hardcoded Ok(match ...), so an infallible enum-to-enum declaration failed with a type mismatch whose span landed on the derive. Variant-to-variant over unit enums cannot fail, so the bare match is the infallible body. --- magic_map/tests/infallible.rs | 22 ++++++++++++++++++++++ magic_map_macros/src/magic_map.rs | 8 +++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/magic_map/tests/infallible.rs b/magic_map/tests/infallible.rs index e2205c8..40736e0 100644 --- a/magic_map/tests/infallible.rs +++ b/magic_map/tests/infallible.rs @@ -74,6 +74,28 @@ fn reference_source_maps_without_moving() { assert_eq!(s.name, "borrowed"); // still ours } +// ── enums: variant-to-variant over unit enums cannot fail ──────────────────── +#[derive(MagicMap, Debug, PartialEq)] +pub enum WireKind { + Image, + Document, +} +#[derive(MagicMap, Debug, PartialEq)] +pub enum DomainKind { + Image, + Document, +} +magic_map!(infallible WireKind => DomainKind); + +#[test] +fn infallible_enum_form_needs_no_question_mark() { + let d: DomainKind = WireKind::Document.map_into(); + assert_eq!(d, DomainKind::Document); + // and the fallible half still comes free + let d: DomainKind = WireKind::Image.try_map_into().expect("cannot fail"); + assert_eq!(d, DomainKind::Image); +} + // ── the claim is not on the honour system ──────────────────────────────────── // String -> Uuid parses, so it has a TryMapFrom route and no MapFrom one. // Uncommenting this must not compile. diff --git a/magic_map_macros/src/magic_map.rs b/magic_map_macros/src/magic_map.rs index 1fbcce3..eae4c2f 100644 --- a/magic_map_macros/src/magic_map.rs +++ b/magic_map_macros/src/magic_map.rs @@ -683,7 +683,13 @@ pub fn expand(raw: TokenStream2, input: ExpandInput) -> Result Date: Fri, 21 Aug 2026 11:53:46 -0600 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20crate-local=20infallible=20funnel?= =?UTF-8?q?=20=E2=80=94=20infallible=20fn-forms=20now=20compose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The infallible expansion funnelled every nested field through the global MapFrom, which a foreign→foreign pair can never implement, so an infallible fn-form mapping could not nest another one — in a layered codebase that is most mappings. magic_map_scope! now plants LocalMapFrom (infallible) beside the fallible funnel, which is renamed LocalTryMapFrom to mirror the 0.4 trait split. Infallible fn-forms register in both. Leaves gained fallibility: map_identity!/map_display! emit the MapFrom twin (identity and Display cannot fail), DateTime→String rfc3339 is infallible, and magic_map_leaves!/scope leaves take an 'infallible' prefix on custom pairs — one MapFrom impl then backs both funnels. --- leaf_provider/src/lib.rs | 16 ++- magic_map/src/lib.rs | 169 ++++++++++++++++++++++-------- magic_map/tests/infallible.rs | 71 +++++++++++++ magic_map_macros/src/magic_map.rs | 110 ++++++++++++++----- 4 files changed, 297 insertions(+), 69 deletions(-) diff --git a/leaf_provider/src/lib.rs b/leaf_provider/src/lib.rs index 12064a9..f6fbaf2 100644 --- a/leaf_provider/src/lib.rs +++ b/leaf_provider/src/lib.rs @@ -6,7 +6,10 @@ magic_map::magic_map_leaves! { identity: [crate::enums::Species], display: [crate::enums::Species], parse: [crate::enums::Species], - custom: [crate::wire::Fahrenheit => String], + custom: [ + crate::wire::Fahrenheit => String, + infallible crate::wire::Celsius => String, + ], } pub mod enums { @@ -30,6 +33,17 @@ pub mod wire { Ok(format!("{}F", src.0)) } } + + #[derive(Clone, Copy, Debug, PartialEq)] + pub struct Celsius(pub i32); + + // An infallible custom leaf carries one `MapFrom` impl; the `infallible` + // entry in the block above backs both local funnels with it. + impl magic_map::MapFrom for String { + fn map_from(src: Celsius) -> Self { + format!("{}C", src.0) + } + } } /// A model carrying both leaves, for a downstream mapper to convert. diff --git a/magic_map/src/lib.rs b/magic_map/src/lib.rs index 963ec11..0462e9b 100644 --- a/magic_map/src/lib.rs +++ b/magic_map/src/lib.rs @@ -389,6 +389,11 @@ mod chrono_leaves { Ok(src.to_rfc3339()) } } + impl super::MapFrom> for String { + fn map_from(src: DateTime) -> Self { + src.to_rfc3339() + } + } // Dates cross the wire as ISO-8601 (`YYYY-MM-DD`) strings — `NaiveDate`'s // canonical `Display`/`FromStr` form (strict on the way in). @@ -447,9 +452,10 @@ impl> MapFieldWrap> for &mut MapPair> } } -/// `map_identity!(MyEnum);` — `TryMapFrom for MyEnum`, so same-typed -/// fields automap (model→model moves, e.g. invite→update). Declare next to -/// the type; the orphan rule keeps it in the owning crate. +/// `map_identity!(MyEnum);` — `TryMapFrom for MyEnum` plus its +/// infallible `MapFrom` twin (an identity cannot fail), so same-typed fields +/// automap in both funnels (model→model moves, e.g. invite→update). Declare +/// next to the type; the orphan rule keeps it in the owning crate. #[macro_export] macro_rules! map_identity { ($($t:ty),+ $(,)?) => {$( @@ -458,11 +464,17 @@ macro_rules! map_identity { Ok(src) } } + impl $crate::MapFrom<$t> for $t { + fn map_from(src: $t) -> Self { + src + } + } )+}; } -/// `map_display!(MyEnum);` — `TryMapFrom for String` via `Display`, so -/// enum→string fields automap (pairs with strum's `Display` derive). +/// `map_display!(MyEnum);` — `TryMapFrom for String` plus its +/// infallible `MapFrom` twin (`Display` cannot fail), so enum→string fields +/// automap in both funnels (pairs with strum's `Display` derive). #[macro_export] macro_rules! map_display { ($($t:ty),+ $(,)?) => {$( @@ -471,6 +483,11 @@ macro_rules! map_display { Ok(src.to_string()) } } + impl $crate::MapFrom<$t> for ::std::string::String { + fn map_from(src: $t) -> Self { + src.to_string() + } + } )+}; } @@ -596,20 +613,22 @@ macro_rules! magic_map_scope { /// Crate-local twin of [`magic_map::TryMapFrom`]. The fn form /// implements it for pairs the orphan rule keeps off `TryMapFrom`; /// leaves are delegated in below. - pub trait LocalMapFrom: Sized { - fn local_map_from(src: S) -> ::core::result::Result; + pub trait LocalTryMapFrom: Sized { + fn local_try_map_from( + src: S, + ) -> ::core::result::Result; } - impl> LocalMapFrom<::core::option::Option> + impl> LocalTryMapFrom<::core::option::Option> for ::core::option::Option { - fn local_map_from( + fn local_try_map_from( src: ::core::option::Option, ) -> ::core::result::Result { match src { ::core::option::Option::Some(s) => { ::core::result::Result::Ok(::core::option::Option::Some( - D::local_map_from(s)?, + D::local_try_map_from(s)?, )) } ::core::option::Option::None => { @@ -619,10 +638,36 @@ macro_rules! magic_map_scope { } } - impl> LocalMapFrom<::std::vec::Vec> for ::std::vec::Vec { - fn local_map_from( + impl> LocalTryMapFrom<::std::vec::Vec> + for ::std::vec::Vec + { + fn local_try_map_from( src: ::std::vec::Vec, ) -> ::core::result::Result { + src.into_iter().map(D::local_try_map_from).collect() + } + } + + /// Crate-local twin of [`magic_map::MapFrom`] — the infallible + /// funnel. Like its fallible sibling it is a CLOSED WORLD: only + /// infallible fn-form mappings and leaves that genuinely cannot + /// fail are delegated in, which is what lets an `infallible` + /// fn-form mapping nest another foreign→foreign mapping without + /// giving up the no-`?` check. + pub trait LocalMapFrom: Sized { + fn local_map_from(src: S) -> Self; + } + + impl> LocalMapFrom<::core::option::Option> + for ::core::option::Option + { + fn local_map_from(src: ::core::option::Option) -> Self { + src.map(D::local_map_from) + } + } + + impl> LocalMapFrom<::std::vec::Vec> for ::std::vec::Vec { + fn local_map_from(src: ::std::vec::Vec) -> Self { src.into_iter().map(D::local_map_from).collect() } } @@ -638,12 +683,12 @@ macro_rules! magic_map_scope { pub trait LocalFieldOpt { fn local_map_field_or(self) -> ::core::result::Result; } - impl> LocalFieldOpt + impl> LocalFieldOpt for &mut &mut &mut $crate::MapPair<::core::option::Option, D> { fn local_map_field_or(self) -> ::core::result::Result { match self.0.take().expect("magic_map field consumed twice") { - ::core::option::Option::Some(s) => D::local_map_from(s), + ::core::option::Option::Some(s) => D::local_try_map_from(s), ::core::option::Option::None => ::core::result::Result::Ok( self.1.take().expect("magic_map fallback consumed twice"), ), @@ -654,16 +699,16 @@ macro_rules! magic_map_scope { pub trait LocalFieldVal { fn local_map_field_or(self) -> ::core::result::Result; } - impl> LocalFieldVal for &mut &mut $crate::MapPair { + impl> LocalFieldVal for &mut &mut $crate::MapPair { fn local_map_field_or(self) -> ::core::result::Result { - D::local_map_from(self.0.take().expect("magic_map field consumed twice")) + D::local_try_map_from(self.0.take().expect("magic_map field consumed twice")) } } pub trait LocalFieldWrap { fn local_map_field_or(self) -> ::core::result::Result; } - impl> LocalFieldWrap<::core::option::Option> + impl> LocalFieldWrap<::core::option::Option> for &mut $crate::MapPair> { fn local_map_field_or( @@ -671,16 +716,16 @@ macro_rules! magic_map_scope { ) -> ::core::result::Result<::core::option::Option, $crate::MappingError> { let src = self.0.take().expect("magic_map field consumed twice"); - ::core::result::Result::Ok(::core::option::Option::Some(U::local_map_from( - src, - )?)) + ::core::result::Result::Ok(::core::option::Option::Some( + U::local_try_map_from(src)?, + )) } } } }; } -/// Delegates one `TryMapFrom` pair into the local trait. Used by +/// Delegates one `TryMapFrom` pair into the local fallible trait. Used by /// `magic_map_scope!` for both the built-in leaves and the `leaves: [...]` /// list; the body is a plain call, so a missing `TryMapFrom` impl fails here and /// names the pair. @@ -688,19 +733,53 @@ macro_rules! magic_map_scope { #[macro_export] macro_rules! __magic_map_scope_delegate { ($src:ty => $dest:ty) => { - impl LocalMapFrom<$src> for $dest { - fn local_map_from(src: $src) -> ::core::result::Result { + impl LocalTryMapFrom<$src> for $dest { + fn local_try_map_from( + src: $src, + ) -> ::core::result::Result { <$dest as $crate::TryMapFrom<$src>>::try_map_from(src) } } }; } -/// The `leaves: [...]` entries. A bare type is its identity. +/// Delegates one `MapFrom` pair into the local infallible trait — and into the +/// fallible one, so an infallible leaf serves both funnels from a single +/// declaration. A pair that is not actually infallible fails here on the +/// missing `MapFrom` impl, naming both types. +#[doc(hidden)] +#[macro_export] +macro_rules! __magic_map_scope_delegate_infallible { + ($src:ty => $dest:ty) => { + impl LocalMapFrom<$src> for $dest { + fn local_map_from(src: $src) -> Self { + <$dest as $crate::MapFrom<$src>>::map_from(src) + } + } + impl LocalTryMapFrom<$src> for $dest { + fn local_try_map_from( + src: $src, + ) -> ::core::result::Result { + ::core::result::Result::Ok(<$dest as $crate::MapFrom<$src>>::map_from(src)) + } + } + }; +} + +/// The `leaves: [...]` entries. A bare type is its identity (infallible by +/// definition); `infallible Src => Dest` requires a `MapFrom` route and feeds +/// both funnels; a plain `Src => Dest` delegates fallibly. #[doc(hidden)] #[macro_export] macro_rules! __magic_map_scope_extra_leaves { () => {}; + (infallible $src:ty => $dest:ty, $($rest:tt)*) => { + $crate::__magic_map_scope_delegate_infallible!($src => $dest); + $crate::__magic_map_scope_extra_leaves!($($rest)*); + }; + (infallible $src:ty => $dest:ty $(,)?) => { + $crate::__magic_map_scope_delegate_infallible!($src => $dest); + }; ($src:ty => $dest:ty, $($rest:tt)*) => { $crate::__magic_map_scope_delegate!($src => $dest); $crate::__magic_map_scope_extra_leaves!($($rest)*); @@ -709,11 +788,11 @@ macro_rules! __magic_map_scope_extra_leaves { $crate::__magic_map_scope_delegate!($src => $dest); }; ($t:ty, $($rest:tt)*) => { - $crate::__magic_map_scope_delegate!($t => $t); + $crate::__magic_map_scope_delegate_infallible!($t => $t); $crate::__magic_map_scope_extra_leaves!($($rest)*); }; ($t:ty $(,)?) => { - $crate::__magic_map_scope_delegate!($t => $t); + $crate::__magic_map_scope_delegate_infallible!($t => $t); }; } @@ -731,8 +810,10 @@ macro_rules! __magic_map_scope_generic_leaves { ( < $($gen:tt),* $(,)? > $src:ty => $dest:ty ; $($rest:tt)* ) => { - impl< $($gen),* > LocalMapFrom<$src> for $dest { - fn local_map_from(src: $src) -> ::core::result::Result { + impl< $($gen),* > LocalTryMapFrom<$src> for $dest { + fn local_try_map_from( + src: $src, + ) -> ::core::result::Result { <$dest as $crate::TryMapFrom<$src>>::try_map_from(src) } } @@ -759,8 +840,10 @@ macro_rules! __magic_map_scope_generic_one { #[macro_export] macro_rules! __magic_map_scope_generic_split { ( [ $($gen:tt),* ] [ $src:ty ] [ $dest:ty ] [ $($bound:tt)* ] ; $($rest:tt)* ) => { - impl< $($gen),* > LocalMapFrom<$src> for $dest where $($bound)* { - fn local_map_from(src: $src) -> ::core::result::Result { + impl< $($gen),* > LocalTryMapFrom<$src> for $dest where $($bound)* { + fn local_try_map_from( + src: $src, + ) -> ::core::result::Result { <$dest as $crate::TryMapFrom<$src>>::try_map_from(src) } } @@ -791,15 +874,17 @@ macro_rules! __magic_map_scope_leaves { u8, u16, u32, u64, u128, usize, f32, f64, ::std::string::String, ); - // Lossless widenings. + // Lossless widenings — infallible by definition. $crate::__magic_map_scope_extra_leaves!( - u8 => u16, u8 => u32, u8 => u64, u8 => i16, u8 => i32, u8 => i64, - u16 => u32, u16 => u64, u16 => i32, u16 => i64, - u32 => u64, u32 => i64, - i8 => i16, i8 => i32, i8 => i64, - i16 => i32, i16 => i64, - i32 => i64, - f32 => f64, + infallible u8 => u16, infallible u8 => u32, infallible u8 => u64, + infallible u8 => i16, infallible u8 => i32, infallible u8 => i64, + infallible u16 => u32, infallible u16 => u64, + infallible u16 => i32, infallible u16 => i64, + infallible u32 => u64, infallible u32 => i64, + infallible i8 => i16, infallible i8 => i32, infallible i8 => i64, + infallible i16 => i32, infallible i16 => i64, + infallible i32 => i64, + infallible f32 => f64, ); $crate::__magic_map_scope_uuid_leaves!(); $crate::__magic_map_scope_decimal_leaves!(); @@ -816,7 +901,7 @@ macro_rules! __magic_map_scope_uuid_leaves { $crate::__magic_map_scope_extra_leaves!( ::uuid::Uuid, ::std::string::String => ::uuid::Uuid, - ::uuid::Uuid => ::std::string::String, + infallible ::uuid::Uuid => ::std::string::String, ); }; } @@ -837,7 +922,7 @@ macro_rules! __magic_map_scope_decimal_leaves { ::rust_decimal::Decimal => f64, f64 => ::rust_decimal::Decimal, ::std::string::String => ::rust_decimal::Decimal, - ::rust_decimal::Decimal => ::std::string::String, + infallible ::rust_decimal::Decimal => ::std::string::String, ); }; } @@ -859,9 +944,9 @@ macro_rules! __magic_map_scope_chrono_leaves { ::chrono::NaiveDateTime, ::chrono::NaiveTime, ::std::string::String => ::chrono::DateTime<::chrono::Utc>, - ::chrono::DateTime<::chrono::Utc> => ::std::string::String, + infallible ::chrono::DateTime<::chrono::Utc> => ::std::string::String, ::std::string::String => ::chrono::NaiveDate, - ::chrono::NaiveDate => ::std::string::String, + infallible ::chrono::NaiveDate => ::std::string::String, ); }; } diff --git a/magic_map/tests/infallible.rs b/magic_map/tests/infallible.rs index 40736e0..e35c990 100644 --- a/magic_map/tests/infallible.rs +++ b/magic_map/tests/infallible.rs @@ -96,6 +96,77 @@ fn infallible_enum_form_needs_no_question_mark() { assert_eq!(d, DomainKind::Image); } +// ── infallible fn-forms compose through the crate-local funnel ─────────────── +// The whole reason the local infallible funnel exists: a foreign→foreign pair +// has no `MapFrom` impl anywhere, so without it an infallible mapping could +// never nest another one. +pub mod inner { + #[derive(magic_map::MagicMap, Clone, Debug, PartialEq)] + pub struct InnerSrc { + pub label: String, + } + #[derive(magic_map::MagicMap, Debug, PartialEq)] + pub struct InnerDest { + pub label: String, + } +} + +#[derive(MagicMap)] +pub struct OuterSrc { + pub one: inner::InnerSrc, + pub many: Vec, + pub temp: leaf_provider::wire::Celsius, + pub species: leaf_provider::enums::Species, // display leaf → String +} +#[derive(MagicMap, Debug)] +pub struct OuterDest { + pub one: inner::InnerDest, + pub many: Vec, + pub temp: String, // infallible custom leaf, via leaves_from + pub species: String, // map_display! now feeds the infallible funnel too +} + +magic_map!(infallible fn inner_dest: inner::InnerSrc => inner::InnerDest); +magic_map!(infallible fn outer_dest: OuterSrc => OuterDest); + +#[test] +fn infallible_fn_forms_nest_without_question_marks() { + let d = outer_dest(OuterSrc { + one: inner::InnerSrc { label: "a".into() }, + many: vec![inner::InnerSrc { label: "b".into() }], + temp: leaf_provider::wire::Celsius(21), + species: leaf_provider::enums::Species::Lion, + }); + assert_eq!(d.one.label, "a"); + assert_eq!(d.many[0].label, "b"); + assert_eq!(d.temp, "21C"); + assert_eq!(d.species, "Lion"); +} + +// And the infallible fn-form still serves fallible nesting: a *fallible* +// fn-form auto-fills a field pair registered by the infallible one above. +#[derive(MagicMap)] +pub struct MixedSrc { + pub one: inner::InnerSrc, + pub id: String, // String → Uuid parses: keeps the outer mapping fallible +} +#[derive(MagicMap, Debug)] +pub struct MixedDest { + pub one: inner::InnerDest, + pub id: Uuid, +} +magic_map!(fn mixed_dest: MixedSrc => MixedDest); + +#[test] +fn a_fallible_fn_form_nests_an_infallible_one() { + let d = mixed_dest(MixedSrc { + one: inner::InnerSrc { label: "x".into() }, + id: Uuid::nil().to_string(), + }) + .expect("valid uuid"); + assert_eq!(d.one.label, "x"); +} + // ── the claim is not on the honour system ──────────────────────────────────── // String -> Uuid parses, so it has a TryMapFrom route and no MapFrom one. // Uncommenting this must not compile. diff --git a/magic_map_macros/src/magic_map.rs b/magic_map_macros/src/magic_map.rs index eae4c2f..c97b5ed 100644 --- a/magic_map_macros/src/magic_map.rs +++ b/magic_map_macros/src/magic_map.rs @@ -806,16 +806,25 @@ pub fn expand(raw: TokenStream2, input: ExpandInput) -> Result Result for #dest { - fn local_map_from( + fn local_map_from(src: #src) -> Self { + #name(src) + } + } + + // And in the fallible one, wrapping in Ok: a fallible mapping + // nesting this type still auto-fills. + impl crate::__magic_map_scope::LocalTryMapFrom<#src> for #dest { + fn local_try_map_from( src: #src, ) -> ::core::result::Result { ::core::result::Result::Ok(#name(src)) @@ -920,8 +937,8 @@ pub fn expand(raw: TokenStream2, input: ExpandInput) -> Result for #dest { - fn local_map_from( + impl crate::__magic_map_scope::LocalTryMapFrom<#src> for #dest { + fn local_try_map_from( src: #src, ) -> ::core::result::Result { #name(src) @@ -958,7 +975,7 @@ pub struct LeavesInput { identity: Vec, display: Vec, parse: Vec, - custom: Vec<(syn::Type, syn::Type)>, + custom: Vec<(syn::Type, syn::Type, bool)>, } impl Parse for LeavesInput { @@ -982,10 +999,17 @@ impl Parse for LeavesInput { } "custom" => { while !content.is_empty() { + // `infallible` marks a pair that carries a `MapFrom` + // route; contextual keyword, same as in `magic_map!`. + let infallible = content.peek(Ident) + && content.fork().parse::()? == "infallible"; + if infallible { + content.parse::()?; + } let src: syn::Type = content.parse()?; content.parse::]>()?; let dest: syn::Type = content.parse()?; - custom.push((src, dest)); + custom.push((src, dest, infallible)); if content.peek(Token![,]) { content.parse::()?; } @@ -1044,29 +1068,63 @@ pub fn leaves(input: LeavesInput) -> Result { }; // The published list is expanded in *another* crate, so own-crate paths - // have to travel as `$crate::`. - let mut pairs: Vec<(TokenStream2, TokenStream2)> = Vec::new(); + // have to travel as `$crate::`. Identities and Display routes are + // infallible by definition; `parse` is fallible; `custom` says which. + let mut pairs: Vec<(TokenStream2, TokenStream2, bool)> = Vec::new(); for p in &identity { let t = republish(quote! { #p }); - pairs.push((t.clone(), t)); + pairs.push((t.clone(), t, true)); } for p in &display { - pairs.push((republish(quote! { #p }), quote! { ::std::string::String })); + pairs.push(( + republish(quote! { #p }), + quote! { ::std::string::String }, + true, + )); } for p in &parse { - pairs.push((quote! { ::std::string::String }, republish(quote! { #p }))); + pairs.push(( + quote! { ::std::string::String }, + republish(quote! { #p }), + false, + )); } - for (src, dest) in &custom { - pairs.push((republish(quote! { #src }), republish(quote! { #dest }))); + for (src, dest, infallible) in &custom { + pairs.push(( + republish(quote! { #src }), + republish(quote! { #dest }), + *infallible, + )); } - let replays = pairs.iter().map(|(src, dest)| { - quote! { - impl LocalMapFrom<#src> for #dest { - fn local_map_from( - src: #src, - ) -> ::core::result::Result { - <#dest as ::magic_map::TryMapFrom<#src>>::try_map_from(src) + // An infallible pair backs BOTH local funnels with its one `MapFrom` + // route, so the owning crate never writes the Ok-wrap twin by hand. + let replays = pairs.iter().map(|(src, dest, infallible)| { + if *infallible { + quote! { + impl LocalMapFrom<#src> for #dest { + fn local_map_from(src: #src) -> Self { + <#dest as ::magic_map::MapFrom<#src>>::map_from(src) + } + } + impl LocalTryMapFrom<#src> for #dest { + fn local_try_map_from( + src: #src, + ) -> ::core::result::Result { + ::core::result::Result::Ok( + <#dest as ::magic_map::MapFrom<#src>>::map_from(src), + ) + } + } + } + } else { + quote! { + impl LocalTryMapFrom<#src> for #dest { + fn local_try_map_from( + src: #src, + ) -> ::core::result::Result { + <#dest as ::magic_map::TryMapFrom<#src>>::try_map_from(src) + } } } } From 0277449c3208ad8428e25388c0745ad5e7b067a0 Mon Sep 17 00:00:00 2001 From: Ricardo Sansores Date: Fri, 21 Aug 2026 16:51:48 -0600 Subject: [PATCH 05/10] =?UTF-8?q?feat:=20magic-map-lint=20=E2=80=94=20flag?= =?UTF-8?q?=20std=20conversion=20impls=20that=20bypass=20the=20funnel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sealing cannot reach types local to the mapping crate, conversions out of a sealed type, or anything unsealed. The lint covers those three: a syn-based binary that walks source trees and flags every impl of From/Into/TryFrom/TryInto. Error conversions (either side named *Error) are exempt — that is how ? bubbles between layers — and the allowlist fails on stale entries, so it only shrinks. MIGRATING.md gains the leaf-marking step and the lint section. --- Cargo.lock | 9 ++ Cargo.toml | 2 +- MIGRATING.md | 39 ++++++++ magic_map_lint/Cargo.toml | 16 +++ magic_map_lint/src/main.rs | 195 +++++++++++++++++++++++++++++++++++++ 5 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 magic_map_lint/Cargo.toml create mode 100644 magic_map_lint/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 532ac42..488d7c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -490,6 +490,15 @@ dependencies = [ "validator", ] +[[package]] +name = "magic_map_lint" +version = "0.4.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "magic_map_macros" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index 2460381..ab17764 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["magic_map", "magic_map_macros", "leaf_provider"] +members = ["magic_map", "magic_map_macros", "magic_map_lint", "leaf_provider"] # `compat/*` are standalone feature-configuration guards built directly in CI, # kept out of the workspace so feature unification doesn't enable `validate` on # them (which would defeat their purpose). diff --git a/MIGRATING.md b/MIGRATING.md index e187724..32af936 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -153,6 +153,30 @@ let scope = tenant.map_into(); // was: tenant.into(), or try_map_into()? If it does not compile, the mapping was not infallible and the error names the field pair. +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 the same way fallible ones always +nested. + +**Mark your leaves.** `map_identity!` and `map_display!` now emit the +`MapFrom` twin automatically (an identity or a `Display` cannot fail). A +hand-written custom leaf that cannot fail writes one `MapFrom` impl and takes +an `infallible` prefix in the `magic_map_leaves!` block — that one impl then +backs both funnels, local and global: + +```rust +magic_map::magic_map_leaves! { + identity: [crate::FileKind], + custom: [ + infallible crate::TimeZone => String, // impl MapFrom for String + String => crate::TimeZone, // parse: stays fallible + ], +} +``` + +A pair left unmarked keeps working fallibly — marking is what lets it appear +inside `infallible` mappings. + ### 3. Seal, one owning crate at a time (optional) Replace the derive — the attribute must come **first**, since it rewrites the @@ -178,3 +202,18 @@ declaring the mapping rather than by reaching for `__magic_map_new_unchecked` — which is public only because a macro expansion holds no privilege a hand-written line lacks, and which is named to be obvious in review. + +### 4. Lint the escape hatch shut (optional) + +Sealing cannot reach types local to the mapping crate, conversions *out of* a +sealed type, or anything unsealed. `magic-map-lint` covers those: it walks a +source tree and flags every `impl From / Into / TryFrom / TryInto`, which is +the escape hatch that grows back. Error conversions (`impl From +for ApiError` — either side's name ending in `Error`) are exempt, and an +allowlist file holds the cases that are genuinely not mappings; a stale +allowlist entry fails the run, so the list only shrinks. + +```sh +cargo install magic_map_lint +magic-map-lint --allow .magic-map-allow src/ crates/ +``` diff --git a/magic_map_lint/Cargo.toml b/magic_map_lint/Cargo.toml new file mode 100644 index 0000000..9e8e03f --- /dev/null +++ b/magic_map_lint/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "magic_map_lint" +version = "0.4.0" +edition = "2021" +license = "MIT OR Apache-2.0" +description = "Finds hand-written std conversion impls (From/Into/TryFrom/TryInto) that bypass magic_map" +repository = "https://github.com/rsansores/magic_map" + +[[bin]] +name = "magic-map-lint" +path = "src/main.rs" + +[dependencies] +syn = { version = "2", default-features = false, features = ["full", "parsing", "visit", "printing"] } +proc-macro2 = { version = "1", features = ["span-locations"] } +quote = "1" diff --git a/magic_map_lint/src/main.rs b/magic_map_lint/src/main.rs new file mode 100644 index 0000000..ca22cb2 --- /dev/null +++ b/magic_map_lint/src/main.rs @@ -0,0 +1,195 @@ +//! Finds hand-written std conversion impls in a codebase that maps with +//! `magic_map`. +//! +//! One mapping mechanism means `From` / `Into` / `TryFrom` / `TryInto` impls +//! are the escape hatch that grows back — a conversion written there skips +//! the leaf funnel, the infallibility check, and sealing. This walks the +//! given paths and flags every such impl, with two deliberate exemptions: +//! +//! * **Error conversions.** `impl From for ApiError` is how `?` +//! bubbles between layers; an impl where either side's type name ends in +//! `Error` is not a data mapping. +//! * **An allowlist**, one rendered signature per line (`# ` starts a +//! comment), for the cases that are genuinely not mappings. The file only +//! ever shrinks: an allowlisted signature that no longer exists fails the +//! run, so stale entries cannot hide new violations. +//! +//! Usage: `magic-map-lint [--allow ] ...` +//! Exit code 1 when violations (or stale allowlist entries) are found. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use syn::visit::Visit; + +const CONVERSION_TRAITS: [&str; 4] = ["From", "TryFrom", "Into", "TryInto"]; + +struct Finding { + file: PathBuf, + line: usize, + signature: String, +} + +struct Finder<'a> { + file: &'a Path, + findings: Vec, +} + +/// The last path segment's identifier, which is how humans read the type. +fn tail(path: &syn::Path) -> Option { + path.segments.last().map(|s| s.ident.to_string()) +} + +fn type_name(ty: &syn::Type) -> Option { + match ty { + syn::Type::Path(p) => tail(&p.path), + syn::Type::Reference(r) => type_name(&r.elem), + _ => None, + } +} + +/// The trait's first generic argument (`From for ...`). +fn trait_arg_name(path: &syn::Path) -> Option { + let seg = path.segments.last()?; + if let syn::PathArguments::AngleBracketed(args) = &seg.arguments { + args.args.iter().find_map(|a| match a { + syn::GenericArgument::Type(t) => type_name(t), + _ => None, + }) + } else { + None + } +} + +fn is_error_name(name: &Option) -> bool { + name.as_deref().is_some_and(|n| n.ends_with("Error")) +} + +impl Visit<'_> for Finder<'_> { + fn visit_item_impl(&mut self, item: &syn::ItemImpl) { + if let Some((_, trait_path, _)) = &item.trait_ { + if let Some(trait_name) = tail(trait_path) { + if CONVERSION_TRAITS.contains(&trait_name.as_str()) { + let source = trait_arg_name(trait_path); + let dest = type_name(&item.self_ty); + if !is_error_name(&source) && !is_error_name(&dest) { + let sig = format!( + "impl {}<{}> for {}", + trait_name, + source.as_deref().unwrap_or("_"), + dest.as_deref().unwrap_or("_"), + ); + self.findings.push(Finding { + file: self.file.to_path_buf(), + line: item.impl_token.span.start().line, + signature: sig, + }); + } + } + } + } + syn::visit::visit_item_impl(self, item); + } +} + +fn walk(path: &Path, out: &mut Vec) { + if path.is_dir() { + // Skip build output; everything else is fair game. + if path.file_name().is_some_and(|n| n == "target") { + return; + } + let Ok(entries) = std::fs::read_dir(path) else { + return; + }; + for entry in entries.flatten() { + walk(&entry.path(), out); + } + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path.to_path_buf()); + } +} + +fn main() -> ExitCode { + let mut args = std::env::args().skip(1).peekable(); + let mut allow_file = None; + let mut roots = Vec::new(); + while let Some(arg) = args.next() { + match arg.as_str() { + "--allow" => allow_file = args.next().map(PathBuf::from), + _ => roots.push(PathBuf::from(arg)), + } + } + if roots.is_empty() { + eprintln!("usage: magic-map-lint [--allow ] ..."); + return ExitCode::from(2); + } + + let allowed: BTreeSet = allow_file + .as_deref() + .map(|f| { + std::fs::read_to_string(f) + .unwrap_or_else(|e| panic!("cannot read allowlist {}: {e}", f.display())) + .lines() + .map(str::trim) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .map(String::from) + .collect() + }) + .unwrap_or_default(); + + let mut files = Vec::new(); + for root in &roots { + walk(root, &mut files); + } + files.sort(); + + let mut findings = Vec::new(); + for file in &files { + let Ok(source) = std::fs::read_to_string(file) else { + continue; + }; + let Ok(ast) = syn::parse_file(&source) else { + // Unparseable files are the compiler's problem, not ours. + continue; + }; + let mut finder = Finder { + file, + findings: Vec::new(), + }; + finder.visit_file(&ast); + findings.extend(finder.findings); + } + + let mut used: BTreeSet<&str> = BTreeSet::new(); + let mut violations = 0; + for f in &findings { + if allowed.contains(&f.signature) { + used.insert(&f.signature); + } else { + println!( + "{}:{}: {} — declare it with magic_map! instead", + f.file.display(), + f.line, + f.signature + ); + violations += 1; + } + } + + // Ratchet: an allowlist entry nothing matched is debt already paid off. + let mut stale = 0; + for a in &allowed { + if !used.contains(a.as_str()) { + println!("stale allowlist entry (remove it): {a}"); + stale += 1; + } + } + + if violations + stale > 0 { + println!("{violations} conversion impl(s) outside magic_map, {stale} stale allowlist entr(ies)"); + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + } +} From 9c68f29390a9c2c71f69033731e7529803a23dfe Mon Sep 17 00:00:00 2001 From: Ricardo Sansores Date: Fri, 21 Aug 2026 17:36:35 -0600 Subject: [PATCH 06/10] fix: mapped(sealed) owns the export override and strips the helper attr The attribute form parsed export = ".." and threw it away, and left the #[magic_map(export)] helper dangling on the re-emitted item where no derive remains to claim it. Both forms now feed schema_for the same override, and prost trees can seal blanket-style while keeping their per-type export disambiguations. --- magic_map_macros/src/magic_map.rs | 35 +++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/magic_map_macros/src/magic_map.rs b/magic_map_macros/src/magic_map.rs index c97b5ed..54a02f6 100644 --- a/magic_map_macros/src/magic_map.rs +++ b/magic_map_macros/src/magic_map.rs @@ -31,7 +31,7 @@ use syn::{Data, DeriveInput, Fields, Ident, Path, Token}; // ── 1. #[derive(MagicMap)] — metadata only ─────────────────────────────────── pub fn derive(input: DeriveInput) -> Result { - schema_for(&input, false) + schema_for(&input, false, None) } /// `#[mapped]` / `#[mapped(sealed)]` — the attribute form. @@ -54,13 +54,15 @@ pub fn derive(input: DeriveInput) -> Result { /// an accusation in review. pub fn mapped(attr: TokenStream2, item: TokenStream2) -> Result { let mut sealed = false; + let mut export = None; if !attr.is_empty() { let parser = syn::meta::parser(|meta| { if meta.path.is_ident("sealed") { sealed = true; Ok(()) } else if meta.path.is_ident("export") { - let _: syn::LitStr = meta.value()?.parse()?; + let v: syn::LitStr = meta.value()?.parse()?; + export = Some(v.value()); Ok(()) } else { Err(meta.error("expected `sealed` or `export = \"UniqueName\"`")) @@ -69,8 +71,25 @@ pub fn mapped(attr: TokenStream2, item: TokenStream2) -> Result match &d.fields { @@ -124,7 +143,11 @@ pub fn mapped(attr: TokenStream2, item: TokenStream2) -> Result Result { +fn schema_for( + input: &DeriveInput, + sealed: bool, + export: Option, +) -> Result { let name = &input.ident; let schema_name = format_ident!("__magic_map_schema_{}", name); @@ -178,7 +201,7 @@ fn schema_for(input: &DeriveInput, sealed: bool) -> Result = None; + let mut export_override: Option = export; for attr in &input.attrs { if attr.path().is_ident("magic_map") { attr.parse_nested_meta(|meta| { From aea750edb33568980a223a5306d8697848f8cc02 Mon Sep 17 00:00:00 2001 From: Ricardo Sansores Date: Fri, 21 Aug 2026 17:42:18 -0600 Subject: [PATCH 07/10] fix: scope leaf groups resolve third-party types through hidden re-exports magic_map_scope! expanded ::uuid::Uuid and friends in the CALLING crate, so a crate mapping only strings still needed uuid, rust_decimal, chrono and serde_json as direct dependencies. The feature-gated groups now name those types through $crate::__rx, which resolves against this crate's own dependency set. --- magic_map/src/lib.rs | 49 +++++++++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/magic_map/src/lib.rs b/magic_map/src/lib.rs index 0462e9b..c55987c 100644 --- a/magic_map/src/lib.rs +++ b/magic_map/src/lib.rs @@ -87,6 +87,21 @@ pub use magic_map_macros::{magic_map, magic_map_leaves, MagicMap, mapped}; #[doc(hidden)] pub use magic_map_macros::__magic_map_expand; +// The scope leaf groups name third-party leaf types through these, so a crate +// calling `magic_map_scope!` does not need uuid/chrono/decimal/json as direct +// dependencies just because this crate's features enable those leaves. +#[doc(hidden)] +pub mod __rx { + #[cfg(feature = "chrono")] + pub use chrono; + #[cfg(feature = "decimal")] + pub use rust_decimal; + #[cfg(feature = "json")] + pub use serde_json; + #[cfg(feature = "uuid")] + pub use uuid; +} + // Re-exported so generated code can reach the `Validate` trait as // `::magic_map::validator::Validate` without the call-site (or a neutral mapper // crate) needing a direct `validator` dependency, and so the `validator` version @@ -899,9 +914,9 @@ macro_rules! __magic_map_scope_leaves { macro_rules! __magic_map_scope_uuid_leaves { () => { $crate::__magic_map_scope_extra_leaves!( - ::uuid::Uuid, - ::std::string::String => ::uuid::Uuid, - infallible ::uuid::Uuid => ::std::string::String, + $crate::__rx::uuid::Uuid, + ::std::string::String => $crate::__rx::uuid::Uuid, + infallible $crate::__rx::uuid::Uuid => ::std::string::String, ); }; } @@ -918,11 +933,11 @@ macro_rules! __magic_map_scope_uuid_leaves { macro_rules! __magic_map_scope_decimal_leaves { () => { $crate::__magic_map_scope_extra_leaves!( - ::rust_decimal::Decimal, - ::rust_decimal::Decimal => f64, - f64 => ::rust_decimal::Decimal, - ::std::string::String => ::rust_decimal::Decimal, - infallible ::rust_decimal::Decimal => ::std::string::String, + $crate::__rx::rust_decimal::Decimal, + $crate::__rx::rust_decimal::Decimal => f64, + f64 => $crate::__rx::rust_decimal::Decimal, + ::std::string::String => $crate::__rx::rust_decimal::Decimal, + infallible $crate::__rx::rust_decimal::Decimal => ::std::string::String, ); }; } @@ -939,14 +954,14 @@ macro_rules! __magic_map_scope_decimal_leaves { macro_rules! __magic_map_scope_chrono_leaves { () => { $crate::__magic_map_scope_extra_leaves!( - ::chrono::DateTime<::chrono::Utc>, - ::chrono::NaiveDate, - ::chrono::NaiveDateTime, - ::chrono::NaiveTime, - ::std::string::String => ::chrono::DateTime<::chrono::Utc>, - infallible ::chrono::DateTime<::chrono::Utc> => ::std::string::String, - ::std::string::String => ::chrono::NaiveDate, - infallible ::chrono::NaiveDate => ::std::string::String, + $crate::__rx::chrono::DateTime<$crate::__rx::chrono::Utc>, + $crate::__rx::chrono::NaiveDate, + $crate::__rx::chrono::NaiveDateTime, + $crate::__rx::chrono::NaiveTime, + ::std::string::String => $crate::__rx::chrono::DateTime<$crate::__rx::chrono::Utc>, + infallible $crate::__rx::chrono::DateTime<$crate::__rx::chrono::Utc> => ::std::string::String, + ::std::string::String => $crate::__rx::chrono::NaiveDate, + infallible $crate::__rx::chrono::NaiveDate => ::std::string::String, ); }; } @@ -962,7 +977,7 @@ macro_rules! __magic_map_scope_chrono_leaves { #[macro_export] macro_rules! __magic_map_scope_json_leaves { () => { - $crate::__magic_map_scope_extra_leaves!(::serde_json::Value); + $crate::__magic_map_scope_extra_leaves!($crate::__rx::serde_json::Value); }; } #[cfg(not(feature = "json"))] From 7820d758fb00ed9985c7547c5405baac38fd7912 Mon Sep 17 00:00:00 2001 From: Ricardo Sansores Date: Fri, 21 Aug 2026 18:18:37 -0600 Subject: [PATCH 08/10] =?UTF-8?q?docs:=200.4=20README=20=E2=80=94=20leaf?= =?UTF-8?q?=20fallibility,=20sealed=20prost=20recipe,=20the=20linter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version strings catch up to 0.4; the infallible section explains enum mappings and fn-form composition through the local funnel; leaves gain the fallibility model (map_identity!/map_display! emit the MapFrom twin, 'infallible' custom pairs back both funnels with one impl); the prost recipe seals model packages path-scoped; and magic-map-lint gets its own section — install, exemptions, shrink-only allowlist, and the division of labour with sealing. MIGRATING adds the dependency dividend, the parameter-construction bucket, and the sealed-pattern gotcha. --- MIGRATING.md | 38 +++++++++++++++---- README.md | 101 ++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 119 insertions(+), 20 deletions(-) diff --git a/MIGRATING.md b/MIGRATING.md index 32af936..d329579 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -139,6 +139,11 @@ Word boundaries matter: they leave `map_identity!`, `map_display!`, `map_parse!` and `magic_map_scope!` alone. Re-exports move with everything else. Then build — there is nothing else to do. +One dependency dividend: `magic_map_scope!` no longer expands `::uuid::Uuid` +and friends into *your* crate — the leaf groups resolve through magic_map's +own re-exports. A crate that only kept `uuid`, `chrono`, `rust_decimal` or +`serde_json` as direct dependencies to satisfy the scope can drop them. + ### 2. Make the conversions that cannot fail say so (optional) Add `infallible` and drop the `?`: @@ -188,20 +193,39 @@ item the derives then see: pub struct CustomerDto { … } ``` -For prost, one line, and prefer a path over `"."`: +For prost, one line per package, and prefer a path over `"."` — seal the +model packages (mapping destinations), keep request/args packages on plain +`#[magic_map::mapped]` (clients build those from local state, which is +parameter construction, not mapping): ```rust -.type_attribute(".mypkg.Customer", "#[magic_map::mapped(sealed)]") +.type_attribute(".mypkg.models", "#[magic_map::mapped(sealed)]") +.type_attribute(".mypkg.rpc", "#[magic_map::mapped]") ``` +An export disambiguation rides the attribute itself — +`#[magic_map::mapped(sealed, export = "PkgASale")]` — or the old +`#[magic_map(export = "…")]` helper next to it; both still work. + `#[mapped]` with no argument is exactly `#[derive(MagicMap)]`; the derive stays supported, so this is per-type and can stop wherever you want. -Expect a first pass to fail on real findings, not on the mechanism. Fix them by -declaring the mapping rather than by reaching for -`__magic_map_new_unchecked` — which is public only because a macro expansion -holds no privilege a hand-written line lacks, and which is named to be obvious -in review. +Expect a first pass to fail on real findings, not on the mechanism. Most of +what it flags falls into two buckets: + +- **A real hand-rolled mapping** — declare it. Do not reach for + `__magic_map_new_unchecked`, which is public only because a macro expansion + holds no privilege a hand-written line lacks, and which is named to be + obvious in review. +- **Parameter construction** — query/filter structs, seed and test fixtures, + sparse patches, page envelopes: built from local arguments, with no source + type to map from. Forcing a declaration onto these puts a hand-built struct + next to the hand-built struct it replaced. Leave them on plain `#[mapped]`. + Context-like single-field types can keep the seal by growing an ordinary + constructor instead. + +Destructuring a sealed type in a pattern needs a trailing `..` — +`#[non_exhaustive]` blocks exhaustive patterns along with construction. ### 4. Lint the escape hatch shut (optional) diff --git a/README.md b/README.md index f991915..ecc6e4b 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ touch on the [issue tracker](https://github.com/rsansores/magic_map/issues). ```toml [dependencies] -magic_map = { version = "0.1", features = ["uuid", "chrono", "decimal"] } +magic_map = { version = "0.4", features = ["uuid", "chrono", "decimal"] } ``` Two layers that never import each other, and an empty mapping declaration — @@ -283,9 +283,14 @@ magic_map::magic_map_leaves! { identity: [crate::enums::Species], display: [crate::enums::Species], parse: [crate::enums::Species], - // 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. - custom: [crate::wire::Fahrenheit => String], + 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, + ], } ``` @@ -637,7 +642,7 @@ assert!(matches!(bad, Err(magic_map::MappingError::Validation(_)))); Enable the feature in `Cargo.toml`: ```toml -magic_map = { version = "0.2", features = ["validate"] } +magic_map = { version = "0.4", features = ["validate"] } ``` Validation runs after all field conversions succeed — a type error (e.g. a bad @@ -678,12 +683,22 @@ 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](#leaf-fallibility). + Sources may be borrowed: ```rust magic_map!(infallible fn fiscal: &CompanyPayload => FiscalFields { … }); ``` +One restriction: the `..Default::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 @@ -720,10 +735,52 @@ sealed type, or anything not sealed. `#[mapped]` with no argument is exactly `#[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](#linting--magic-map-lint). + +## 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): + +```sh +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 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: + + ```text + # newtype ↔ inner ergonomics, not a layer mapping + impl From for TimeZone + impl From 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 `TryMapFrom` impl for a known type pair. Identities for primitives -and `String` ship always; third-party leaves are feature-gated: +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 | |------------|-------------------| @@ -763,6 +820,15 @@ impl magic_map::TryMapFrom for chrono::DateTime { } ``` + +**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!`](#reaching-your-leaves) instead — same impls, plus the published list a consumer's scope replays. @@ -771,18 +837,27 @@ instead — same impls, plus the published list a consumer's scope replays. ## prost / generated-code recipe -In `build.rs`, plant the derive on every generated type: +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. ```rust prost_build::Config::new() - .type_attribute(".", "#[derive(magic_map::MagicMap)]") - .compile_protos(&["proto/models.proto"], &["proto/"])?; + // 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, so the blanket attribute is safe. If two same-named types exist in one -crate (e.g. `pkg_a.Sale` and `pkg_b.Sale`), disambiguate one's hidden export: -`#[magic_map(export = "PkgASale")]`. +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". From c56944c4730f1655922ddc997efcee0f71340058 Mon Sep 17 00:00:00 2001 From: Ricardo Sansores Date: Fri, 21 Aug 2026 18:19:04 -0600 Subject: [PATCH 09/10] docs: the re-exported-type schema-alias gotcha joins Limitations --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index ecc6e4b..c1a54f3 100644 --- a/README.md +++ b/README.md @@ -875,6 +875,11 @@ decision for the handler that owns the batch, not for the conversion. 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!`](#magic_map_scope--the-fn-forms-crate-local-funnel) From 50df9fbeef930ef54d488ef63cc987ad39f5739b Mon Sep 17 00:00:00 2001 From: Ricardo Sansores Date: Fri, 21 Aug 2026 22:24:25 -0600 Subject: [PATCH 10/10] review: delete a dead macro, one export parser, lean lint crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit __magic_map_leaf_impl! was invoked nowhere and its emitted impl no longer matched the renamed local trait — gone. mapped() re-implemented the #[magic_map(export)] parser schema_for already owns; schema_for now runs before the helper attr is stripped, so one loop reads it and a helper beats the attribute argument. magic_map_lint drops an unused quote dependency and syn's printing feature, and an unreadable --allow path is a usage error (exit 2), not a panic. --- Cargo.lock | 1 - magic_map/src/lib.rs | 31 ++++------------- magic_map/tests/magic_map.rs | 2 +- magic_map/tests/readme.rs | 2 +- magic_map_lint/Cargo.toml | 3 +- magic_map_lint/src/main.rs | 23 ++++++++----- magic_map_macros/src/lib.rs | 4 +-- magic_map_macros/src/magic_map.rs | 56 ++++++++++++++----------------- 8 files changed, 50 insertions(+), 72 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 488d7c5..5ac0181 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -495,7 +495,6 @@ name = "magic_map_lint" version = "0.4.0" dependencies = [ "proc-macro2", - "quote", "syn 2.0.117", ] diff --git a/magic_map/src/lib.rs b/magic_map/src/lib.rs index c55987c..f28e552 100644 --- a/magic_map/src/lib.rs +++ b/magic_map/src/lib.rs @@ -82,7 +82,7 @@ use std::error::Error; use std::fmt; -pub use magic_map_macros::{magic_map, magic_map_leaves, MagicMap, mapped}; +pub use magic_map_macros::{magic_map, magic_map_leaves, mapped, MagicMap}; #[doc(hidden)] pub use magic_map_macros::__magic_map_expand; @@ -327,7 +327,7 @@ impl> MapFrom> for Vec { #[cfg(feature = "uuid")] mod uuid_leaves { - use super::{MapFrom, TryMapFrom, MappingError}; + use super::{MapFrom, MappingError, TryMapFrom}; use uuid::Uuid; impl TryMapFrom for Uuid { @@ -349,7 +349,7 @@ mod uuid_leaves { #[cfg(feature = "decimal")] mod decimal_leaves { - use super::{MapFrom, TryMapFrom, MappingError}; + use super::{MapFrom, MappingError, TryMapFrom}; use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; @@ -386,7 +386,7 @@ mod decimal_leaves { #[cfg(feature = "chrono")] mod chrono_leaves { - use super::{TryMapFrom, MappingError}; + use super::{MappingError, TryMapFrom}; use chrono::{DateTime, NaiveDate, Utc}; /// Canonical wire format for timestamps is rfc3339. @@ -749,9 +749,7 @@ macro_rules! magic_map_scope { macro_rules! __magic_map_scope_delegate { ($src:ty => $dest:ty) => { impl LocalTryMapFrom<$src> for $dest { - fn local_try_map_from( - src: $src, - ) -> ::core::result::Result { + fn local_try_map_from(src: $src) -> ::core::result::Result { <$dest as $crate::TryMapFrom<$src>>::try_map_from(src) } } @@ -772,9 +770,7 @@ macro_rules! __magic_map_scope_delegate_infallible { } } impl LocalTryMapFrom<$src> for $dest { - fn local_try_map_from( - src: $src, - ) -> ::core::result::Result { + fn local_try_map_from(src: $src) -> ::core::result::Result { ::core::result::Result::Ok(<$dest as $crate::MapFrom<$src>>::map_from(src)) } } @@ -986,18 +982,3 @@ macro_rules! __magic_map_scope_json_leaves { macro_rules! __magic_map_scope_json_leaves { () => {}; } - -/// One `LocalMapFrom` impl delegating to an existing `TryMapFrom` pair. Emitted by -/// a crate's replayed leaf list and by `magic_map_scope!`'s own `leaves`; the -/// bare `LocalMapFrom` binds to whichever scope module it lands in. -#[doc(hidden)] -#[macro_export] -macro_rules! __magic_map_leaf_impl { - ($src:ty => $dest:ty) => { - impl LocalMapFrom<$src> for $dest { - fn local_map_from(src: $src) -> ::core::result::Result { - <$dest as $crate::TryMapFrom<$src>>::try_map_from(src) - } - } - }; -} diff --git a/magic_map/tests/magic_map.rs b/magic_map/tests/magic_map.rs index b3b5e89..8cb86c9 100644 --- a/magic_map/tests/magic_map.rs +++ b/magic_map/tests/magic_map.rs @@ -17,7 +17,7 @@ magic_map::magic_map_scope! { }, } use magic_map::magic_map; -use magic_map::{TryMapFrom, TryMapInto, MappingError}; +use magic_map::{MappingError, TryMapFrom, TryMapInto}; use rust_decimal::Decimal; use uuid::Uuid; diff --git a/magic_map/tests/readme.rs b/magic_map/tests/readme.rs index 7e15ad3..5cb3420 100644 --- a/magic_map/tests/readme.rs +++ b/magic_map/tests/readme.rs @@ -11,7 +11,7 @@ // `magic_map_scope!` section. Once, at the crate root, no arguments. magic_map::magic_map_scope!(from: [leaf_provider]); -use magic_map::{TryMapInto, MappingError}; +use magic_map::{MappingError, TryMapInto}; // ── Quick start ────────────────────────────────────────────────────────────── diff --git a/magic_map_lint/Cargo.toml b/magic_map_lint/Cargo.toml index 9e8e03f..38c0f8b 100644 --- a/magic_map_lint/Cargo.toml +++ b/magic_map_lint/Cargo.toml @@ -11,6 +11,5 @@ name = "magic-map-lint" path = "src/main.rs" [dependencies] -syn = { version = "2", default-features = false, features = ["full", "parsing", "visit", "printing"] } +syn = { version = "2", default-features = false, features = ["full", "parsing", "visit"] } proc-macro2 = { version = "1", features = ["span-locations"] } -quote = "1" diff --git a/magic_map_lint/src/main.rs b/magic_map_lint/src/main.rs index ca22cb2..50ee612 100644 --- a/magic_map_lint/src/main.rs +++ b/magic_map_lint/src/main.rs @@ -125,18 +125,21 @@ fn main() -> ExitCode { return ExitCode::from(2); } - let allowed: BTreeSet = allow_file - .as_deref() - .map(|f| { - std::fs::read_to_string(f) - .unwrap_or_else(|e| panic!("cannot read allowlist {}: {e}", f.display())) + let allowed: BTreeSet = match allow_file.as_deref() { + None => BTreeSet::new(), + Some(f) => match std::fs::read_to_string(f) { + Err(e) => { + eprintln!("cannot read allowlist {}: {e}", f.display()); + return ExitCode::from(2); + } + Ok(text) => text .lines() .map(str::trim) .filter(|l| !l.is_empty() && !l.starts_with('#')) .map(String::from) - .collect() - }) - .unwrap_or_default(); + .collect(), + }, + }; let mut files = Vec::new(); for root in &roots { @@ -187,7 +190,9 @@ fn main() -> ExitCode { } if violations + stale > 0 { - println!("{violations} conversion impl(s) outside magic_map, {stale} stale allowlist entr(ies)"); + println!( + "{violations} conversion impl(s) outside magic_map, {stale} stale allowlist entr(ies)" + ); ExitCode::FAILURE } else { ExitCode::SUCCESS diff --git a/magic_map_macros/src/lib.rs b/magic_map_macros/src/lib.rs index f863b92..40a9474 100644 --- a/magic_map_macros/src/lib.rs +++ b/magic_map_macros/src/lib.rs @@ -129,7 +129,6 @@ pub fn magic_map_leaves(input: TokenStream) -> TokenStream { .unwrap_or_else(|e| e.to_compile_error().into()) } - /// `#[mapped]` — the attribute form of `#[derive(MagicMap)]`, plus sealing. /// /// `#[mapped]` on its own is exactly the derive: it publishes the type's field @@ -151,6 +150,5 @@ pub fn magic_map_leaves(input: TokenStream) -> TokenStream { /// effect would be to break every `Ok(Empty {})` in the tree. #[proc_macro_attribute] pub fn mapped(attr: TokenStream, item: TokenStream) -> TokenStream { - magic_map::mapped(attr.into(), item.into()) - .unwrap_or_else(|e| e.to_compile_error().into()) + magic_map::mapped(attr.into(), item.into()).unwrap_or_else(|e| e.to_compile_error().into()) } diff --git a/magic_map_macros/src/magic_map.rs b/magic_map_macros/src/magic_map.rs index 54a02f6..392ddaf 100644 --- a/magic_map_macros/src/magic_map.rs +++ b/magic_map_macros/src/magic_map.rs @@ -72,24 +72,11 @@ pub fn mapped(attr: TokenStream2, item: TokenStream2) -> Result match &d.fields { @@ -819,16 +806,19 @@ pub fn expand(raw: TokenStream2, input: ExpandInput) -> Result Result Result()? == "infallible"; + let infallible = + content.peek(Ident) && content.fork().parse::()? == "infallible"; if infallible { content.parse::()?; }