From 8c4e9092e50d95b207a1afb1c36122010d9b7deb Mon Sep 17 00:00:00 2001 From: Ricardo Sansores Date: Sun, 9 Aug 2026 15:43:09 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20magic=5Fmap=5Fscope!=20=E2=80=94=20nest?= =?UTF-8?q?ed=20funnelling=20for=20foreign=E2=86=92foreign=20mappings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fn form exists because `impl MapFrom for Dest` is only legal in a crate that owns one of the two types. But a mapping's *fields* funnelled through `MapFrom` as well, and the fn form leaves no impl behind — 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. Mapper crates owning neither side were writing the recursion by hand, per field and per collection. `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 `impl LocalMapFrom
for AddressResponse` is legal there even with both types foreign, and the fn form now emits one for every mapping it declares. Nested pairs, `Option`, `Vec` and the `..Default::default()` adaptor compose again — which is what lets a services layer hold the mappers between a `*_db` crate and a `*_dtos` crate that know nothing about each other. Nothing else is ever registered. Field resolution probes two tiers by autoref and the fn form emits CONCRETE tier-1 impls, one per declared pair, so a leaf pair has no tier-1 candidate and probing derefs to the tier-2 blanket over `MapFrom`. Built-in leaves, `map_identity!`/`map_display!`/`map_parse!` declarations, hand-written impls and generic wrappers like a `Patch` update field are all found without being named. A *blanket* tier 1 would instead match every pair structurally and report the unsatisfied bound rather than falling through — method probing selects by receiver shape and never retries a lower tier — which is why the obvious spelling does not work and this one does. Nor can the local trait simply forward to `MapFrom`: that blanket overlaps the per-pair impls and coherence rejects it, because either upstream crate could add the conflicting impl later. Both compiler errors are quoted in the README so the next person does not re-derive them. Breaking: a crate using the fn form must call `magic_map_scope!()` in its crate root. It takes no arguments. Impl-form-only crates are unaffected. Verified against a workspace with 38 leaf declarations across three crates: every one resolved with a bare scope call, including `Patch`. Tests cover the nested foreign→foreign case, None/empty propagation, custom and generic leaves, and leaf fallthrough asserted alongside a declared pair in the same scope, so it is a real fallthrough and not tier 1 being absent. `compat/scope-downstream` is a crate with no features of its own that maps every built-in leaf: it guards the cfg-leakage class of bug, where an emitted `#[cfg(feature = "uuid")]` is evaluated against the *calling* crate and silently drops every gated leaf. magic_map's own test crate cannot catch that, because its features are this crate's. CI builds it. Known edge, documented: a declared pair nested inside a *custom* generic wrapper (`Patch
` → `Patch`, unlike `Patch` → `Patch`) has no tier-1 candidate and wants an explicit override. `Option` and `Vec` over declared pairs are emitted. --- .github/workflows/ci.yml | 1 + Cargo.lock | 4 +- Cargo.toml | 2 +- README.md | 117 ++++++++++++- compat/scope-downstream/Cargo.toml | 24 +++ compat/scope-downstream/src/lib.rs | 51 ++++++ magic_map/Cargo.toml | 2 +- magic_map/src/lib.rs | 272 +++++++++++++++++++++++++++++ magic_map/tests/magic_map.rs | 263 ++++++++++++++++++++++++++++ magic_map/tests/readme.rs | 79 +++++++++ magic_map_macros/src/magic_map.rs | 59 ++++++- 11 files changed, 861 insertions(+), 13 deletions(-) create mode 100644 compat/scope-downstream/Cargo.toml create mode 100644 compat/scope-downstream/src/lib.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2006fb..14462b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,3 +24,4 @@ jobs: # Gating guard: `validator` users who don't enable `magic_map/validate` # must still compile (no `.validate()` call is generated). - run: cargo build --manifest-path compat/no-validate-feature/Cargo.toml + - run: cargo build --manifest-path compat/scope-downstream/Cargo.toml diff --git a/Cargo.lock b/Cargo.lock index 7980193..6d779b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -469,7 +469,7 @@ checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "magic_map" -version = "0.2.1" +version = "0.3.0" dependencies = [ "chrono", "magic_map", @@ -483,7 +483,7 @@ dependencies = [ [[package]] name = "magic_map_macros" -version = "0.2.1" +version = "0.3.0" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index b0f55a5..f64ef47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = ["magic_map", "magic_map_macros"] exclude = ["compat"] [workspace.package] -version = "0.2.1" +version = "0.3.0" edition = "2021" license = "MIT OR Apache-2.0" repository = "https://github.com/rsansores/magic_map" diff --git a/README.md b/README.md index a2962e9..ffa97c4 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,17 @@ disappears in either direction. When **neither** type is local — `wire::Lion` comes out of prost codegen, `db::Lion` out of the ORM, and you're mapping them in a neutral service crate — the orphan rule forbids any trait impl. The fn form generates a plain -function instead: +function instead. + +> **One-time setup:** a crate that uses the fn form must call +> [`magic_map_scope!()`](#magic_map_scope--the-fn-forms-crate-local-funnel) +> once in its crate root. Skip it and the first fn-form mapping fails with +> ``could not find `__magic_map_scope` in the crate root``. + +```rust +// src/lib.rs — once per crate +magic_map::magic_map_scope!(); +``` ```rust mod wire { @@ -215,6 +225,99 @@ let bad = lion_to_db(wire::Lion { id: "not-a-uuid".into(), name: "?".into() }); assert!(bad.is_err()); ``` +### `magic_map_scope!` — the fn form's crate-local funnel + +**When you need it:** any crate that declares a fn-form mapping. Call it once, +at the crate root (`lib.rs`, `main.rs`, or the top of an integration test — +integration tests are their own crate). Crates whose mappings are all impl +form never call it. + +**Why it exists.** A mapping's *fields* funnel through `MapFrom` too. That is +fine for the impl form, which leaves a `MapFrom` 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: + +```rust +// the old way — every nested pair spelled out +magic_map!(pub fn postal_code_to_dto: db::PostalCode => dtos::PostalCodeResponse { + state: state_to_dto(src.state)?, + locality: src.locality.map(locality_to_dto).transpose()?, + neighborhoods: src.neighborhoods.into_iter() + .map(locality_to_dto).collect::>()?, +}); +``` + +`magic_map_scope!()` plants a trait in *your* crate. The orphan rule is +satisfied by a local trait just as well as by a local type, so +`impl LocalMapFrom
for AddressResponse` is legal there even though +both types are foreign — and the fn form now emits one for every mapping it +declares. Nested pairs, `Option`, `Vec` and the `..Default::default()` adaptor +all compose again: + +```rust +magic_map!(pub fn state_to_dto: db::State => dtos::StateResponse); +magic_map!(pub fn locality_to_dto: db::Locality => dtos::LocalityResponse); + +// nested State, Option, Vec — no overrides needed +magic_map!(pub fn postal_code_to_dto: db::PostalCode => dtos::PostalCodeResponse); +``` + +This is what lets mappers live in a crate that owns neither side — a services +layer between a `*_db` crate and a `*_dtos` crate, with no dependency edge +between the two. + +#### Leaves need no declaration + +Nothing to configure — not the built-in leaves, not your `map_identity!` / +`map_display!` / `map_parse!` declarations, not a `MapFrom` impl you wrote by +hand, not a generic wrapper like a `Patch` update field. Declaring a mapping +is the only way anything is ever registered. + +That falls out of how the tiers are built. Field resolution probes two of them +by autoref, and the fn form emits **concrete** tier-1 impls — one per declared +pair. A leaf pair therefore has no tier-1 candidate at all, so probing derefs +straight to the tier-2 blanket over `MapFrom` and finds it there. + +The obvious alternative does not work, which is worth knowing if you ever touch +this code. A *blanket* tier 1 — + +```rust +impl> ProbeLocal for &mut &mut MapProbe { .. } +``` + +— matches every pair structurally, so the compiler commits to it and then +reports the unsatisfied bound rather than falling through: + +```text +error[E0277]: the trait bound `u16: LocalMapFrom` is not satisfied +``` + +Method probing selects a candidate by receiver shape and does **not** retry a +lower tier when a where-bound fails. Concrete impls are what make the fallback +real. + +#### Why the trait has to be local + +The shortcut — one blanket forwarding every existing `MapFrom` into the local +trait — is not expressible either: + +```rust +// rejected by coherence +impl> LocalMapFrom for D { .. } +``` + +```text +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 +``` + +It overlaps the per-pair impls and the compiler cannot rule that out, because +either upstream crate could add the impl later. Hence a local trait carrying +only declared pairs, and the probe reaching `MapFrom` for everything else. + ### `let` preludes Shared derivations that feed more than one destination field: @@ -566,6 +669,10 @@ impl magic_map::MapFrom for chrono::DateTime { } ``` +These automap everywhere — impl form and fn form alike. A fn-form crate needs +[`magic_map_scope!()`](#magic_map_scope--the-fn-forms-crate-local-funnel) in +its root, but nothing about your leaves has to be repeated there. + [strum]: https://crates.io/crates/strum ## prost / generated-code recipe @@ -601,6 +708,14 @@ decision for the handler that owns the batch, not for the conversion. crate-root export — rename one or use `#[magic_map(export = "...")]`. - 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) + in the crate root — once, no arguments. Coherence allows no automatic bridge + from `MapFrom`, so the local trait cannot simply be derived; see that section + for the compiler's own reasoning. +- A declared pair nested inside a *custom generic wrapper* (`Patch
` → + `Patch`, as opposed to `Patch` → `Patch`) + has no tier-1 candidate; give that field an explicit override. Wrappers over + leaves, and `Option`/`Vec` over declared pairs, are handled. ## License diff --git a/compat/scope-downstream/Cargo.toml b/compat/scope-downstream/Cargo.toml new file mode 100644 index 0000000..de86eed --- /dev/null +++ b/compat/scope-downstream/Cargo.toml @@ -0,0 +1,24 @@ +# Standalone crate (own `[workspace]` so the parent does not absorb it) that +# uses `magic_map_scope!` as a real downstream user would: it enables +# magic_map's `full` feature but declares no features of its own. +# +# This guards a cfg-leakage regression. The scope's built-in leaves must be +# emitted from macros defined under `#[cfg(feature = ...)]` *inside* magic_map. +# An emitted `#[cfg(feature = "uuid")]` would instead be evaluated against THIS +# crate's feature set — which is empty — silently dropping every Uuid, chrono, +# Decimal and json leaf, and only failing much later in a user's nested field. +# magic_map's own test crate cannot catch that: its features are magic_map's. +[workspace] + +[package] +name = "scope-downstream" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +magic_map = { path = "../../magic_map", features = ["full"] } +uuid = { version = "1", features = ["v4"] } +chrono = "0.4" +rust_decimal = "1" +serde_json = "1" diff --git a/compat/scope-downstream/src/lib.rs b/compat/scope-downstream/src/lib.rs new file mode 100644 index 0000000..f748ded --- /dev/null +++ b/compat/scope-downstream/src/lib.rs @@ -0,0 +1,51 @@ +//! Regression guard: feature-gated scope leaves must survive the trip into a +//! crate that declares no features of its own. If this compiles, they did. + +magic_map::magic_map_scope!(); + +use magic_map::magic_map; + +pub mod db { + #[derive(magic_map::MagicMap)] + pub struct Row { + pub id: uuid::Uuid, + pub created_at: chrono::DateTime, + pub day: chrono::NaiveDate, + pub amount: rust_decimal::Decimal, + pub payload: serde_json::Value, + pub count: i32, + } +} + +pub mod dtos { + #[derive(magic_map::MagicMap)] + pub struct RowResponse { + pub id: String, // Uuid → String + pub created_at: String, // DateTime → String + pub day: String, // NaiveDate → String + pub amount: f64, // Decimal → f64 + pub payload: serde_json::Value, // identity + pub count: i64, // i32 → i64 widening + } + + #[derive(magic_map::MagicMap)] + pub struct PageResponse { + pub rows: Vec, + pub first: Option, + } +} + +pub mod db_page { + #[derive(magic_map::MagicMap)] + pub struct Page { + pub rows: Vec, + pub first: Option, + } +} + +// Every field here is a built-in leaf reached through the scope. +magic_map!(pub fn row_to_dto: db::Row => dtos::RowResponse); + +// And the nested foreign→foreign pair, through Vec and Option — no overrides, +// which is the whole point: `row_to_dto`'s pair is visible to this mapping. +magic_map!(pub fn page_to_dto: db_page::Page => dtos::PageResponse); diff --git a/magic_map/Cargo.toml b/magic_map/Cargo.toml index 9c0f4f2..744fcbb 100644 --- a/magic_map/Cargo.toml +++ b/magic_map/Cargo.toml @@ -11,7 +11,7 @@ authors.workspace = true readme = "../README.md" [dependencies] -magic_map_macros = { version = "=0.2.1", path = "../magic_map_macros" } +magic_map_macros = { version = "=0.3.0", path = "../magic_map_macros" } chrono = { version = "0.4", optional = true } rust_decimal = { version = "1", optional = true } serde_json = { version = "1", optional = true } diff --git a/magic_map/src/lib.rs b/magic_map/src/lib.rs index 4713bd4..61abebb 100644 --- a/magic_map/src/lib.rs +++ b/magic_map/src/lib.rs @@ -14,6 +14,12 @@ //! can be injected through codegen config (e.g. prost-build's //! `type_attribute`). //! +//! A crate using the fn form calls [`magic_map_scope!`] once in its crate +//! root. That plants the crate-local funnel the generated functions resolve +//! nested fields through, so foreign→foreign mappings compose with each other +//! the same way impl-form ones do — see the macro's docs for the whole story, +//! including why custom leaves have to be named there. +//! //! ``` //! use magic_map::{magic_map, MagicMap, MapInto}; //! @@ -432,3 +438,269 @@ 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 +// 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 +// had nothing to resolve against: `Vec
` → `Vec` needs +// `AddressResponse: MapFrom
`, 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 +// `impl LocalMapFrom
for AddressResponse` is legal there even though +// both types are foreign — and the fn form emits one for every mapping it +// declares. +// +// Leaves need no declaration. Field resolution probes two tiers by autoref: +// the fn form emits CONCRETE tier-1 impls, one per declared pair, so a leaf +// pair has no tier-1 candidate at all and probing derefs to the tier-2 blanket +// over `MapFrom`. That is the whole trick — a *blanket* tier 1 would match +// every pair structurally and then hard-error on its unsatisfied bound instead +// of falling through, which is why the tiers cannot be written the obvious way. +// One consequence worth stating: there is exactly one way to register a +// conversion, which is to declare it. Nothing else needs configuring, ever. + +/// Declares the crate-local mapping funnel that the fn form of `magic_map!` +/// resolves nested fields through. Call it **once, in the crate root** +/// (`lib.rs` / `main.rs`) of any crate that declares a fn-form mapping: +/// +/// ```ignore +/// // src/lib.rs +/// magic_map::magic_map_scope!(); +/// ``` +/// +/// It takes no configuration. Leaves — the built-in ones, your +/// [`map_identity!`] / [`map_display!`] / [`map_parse!`] declarations, and any +/// `MapFrom` impl you wrote by hand — are found without being named. +/// +/// 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 ``could not find `__magic_map_scope` in the crate root`` +/// at the first fn-form mapping. +#[macro_export] +macro_rules! magic_map_scope { + () => { + #[doc(hidden)] + pub mod __magic_map_scope { + //! Generated by `magic_map::magic_map_scope!`. The fn form of + //! `magic_map!` resolves its fields through the traits here. + + #[allow(unused_imports)] + use super::*; + + /// Crate-local twin of [`magic_map::MapFrom`], implemented by the + /// fn form for pairs the orphan rule keeps off `MapFrom`. + pub trait LocalMapFrom: Sized { + fn local_map_from(src: S) -> ::core::result::Result; + } + + impl> LocalMapFrom<::core::option::Option> + for ::core::option::Option + { + fn local_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)?), + ), + ::core::option::Option::None => { + ::core::result::Result::Ok(::core::option::Option::None) + } + } + } + } + + impl> LocalMapFrom<::std::vec::Vec> for ::std::vec::Vec { + fn local_map_from( + src: ::std::vec::Vec, + ) -> ::core::result::Result { + src.into_iter().map(D::local_map_from).collect() + } + } + + // ── Plain field resolution: two tiers ─────────────────────────── + // + // Tier 1 is populated only by the concrete impls the fn form emits + // per declared pair (see `__magic_map_declare_local!`). A leaf has + // no candidate here, so probing derefs to tier 2. + + pub trait ProbeLocal { + fn magic_probe(self) -> ::core::result::Result; + } + + /// Tier 2 — every conversion that already has a `MapFrom` impl: + /// built-in leaves, your own leaf declarations, hand-written impls, + /// and the `Option`/`Vec` blankets over them. + pub trait ProbeGlobal { + fn magic_probe(self) -> ::core::result::Result; + } + impl> ProbeGlobal for &mut $crate::MapProbe { + fn magic_probe(self) -> ::core::result::Result { + D::map_from(self.0.take().expect("magic_map field consumed twice")) + } + } + + // ── `..Default::default()` field resolution: six tiers ────────── + // + // The three shapes of the `MapFrom` version, doubled: local first, + // then global. All six share a method name and are told apart by + // autoref depth, so the local ones win where they have a candidate + // and leaves fall straight through to the global three. + + pub trait LocalFieldOpt { + fn magic_field(self) -> ::core::result::Result; + } + pub trait LocalFieldVal { + fn magic_field(self) -> ::core::result::Result; + } + pub trait LocalFieldWrap { + fn magic_field(self) -> ::core::result::Result; + } + + pub trait GlobalFieldOpt { + fn magic_field(self) -> ::core::result::Result; + } + impl> GlobalFieldOpt + for &mut &mut &mut $crate::MapPair<::core::option::Option, D> + { + fn magic_field(self) -> ::core::result::Result { + match self.0.take().expect("magic_map field consumed twice") { + ::core::option::Option::Some(s) => D::map_from(s), + ::core::option::Option::None => ::core::result::Result::Ok( + self.1.take().expect("magic_map fallback consumed twice"), + ), + } + } + } + + pub trait GlobalFieldVal { + fn magic_field(self) -> ::core::result::Result; + } + impl> GlobalFieldVal for &mut &mut $crate::MapPair { + fn magic_field(self) -> ::core::result::Result { + D::map_from(self.0.take().expect("magic_map field consumed twice")) + } + } + + pub trait GlobalFieldWrap { + fn magic_field(self) -> ::core::result::Result; + } + impl> GlobalFieldWrap<::core::option::Option> + for &mut $crate::MapPair> + { + fn magic_field( + self, + ) -> ::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::map_from(src)?)) + } + } + } + }; +} + +/// Registers one declared pair in the crate-local funnel. Emitted by the fn +/// form of `magic_map!` — the concrete tier-1 impls that let a leaf fall +/// through to `MapFrom` while a declared pair does not. +#[doc(hidden)] +#[macro_export] +macro_rules! __magic_map_declare_local { + // `$scope` is the caller's `crate::__magic_map_scope`, passed in by the + // proc macro rather than spelled `crate::` here — inside a macro_rules + // definition that would read as this crate, which is not what is meant. + ($scope:path, $fn_name:ident, $src:ty, $dest:ty) => { + const _: () = { + use $scope::*; + + impl LocalMapFrom<$src> for $dest { + fn local_map_from(src: $src) -> ::core::result::Result { + $fn_name(src) + } + } + + // Plain resolution, and the two wrappers a DTO field actually uses. + impl ProbeLocal<$dest> for &mut &mut $crate::MapProbe<$src, $dest> { + fn magic_probe(self) -> ::core::result::Result<$dest, $crate::MappingError> { + $fn_name(self.0.take().expect("magic_map field consumed twice")) + } + } + impl ProbeLocal<::std::vec::Vec<$dest>> + for &mut &mut $crate::MapProbe<::std::vec::Vec<$src>, ::std::vec::Vec<$dest>> + { + fn magic_probe( + self, + ) -> ::core::result::Result<::std::vec::Vec<$dest>, $crate::MappingError> { + <::std::vec::Vec<$dest> as LocalMapFrom<::std::vec::Vec<$src>>>::local_map_from( + self.0.take().expect("magic_map field consumed twice"), + ) + } + } + impl ProbeLocal<::core::option::Option<$dest>> + for &mut &mut $crate::MapProbe< + ::core::option::Option<$src>, + ::core::option::Option<$dest>, + > + { + fn magic_probe( + self, + ) -> ::core::result::Result<::core::option::Option<$dest>, $crate::MappingError> + { + <::core::option::Option<$dest> as LocalMapFrom< + ::core::option::Option<$src>, + >>::local_map_from( + self.0.take().expect("magic_map field consumed twice") + ) + } + } + + // `..Default::default()` shapes. + impl LocalFieldOpt<$dest> + for &mut &mut &mut &mut &mut &mut $crate::MapPair< + ::core::option::Option<$src>, + $dest, + > + { + fn magic_field(self) -> ::core::result::Result<$dest, $crate::MappingError> { + match self.0.take().expect("magic_map field consumed twice") { + ::core::option::Option::Some(s) => $fn_name(s), + ::core::option::Option::None => ::core::result::Result::Ok( + self.1.take().expect("magic_map fallback consumed twice"), + ), + } + } + } + impl LocalFieldVal<$dest> for &mut &mut &mut &mut &mut $crate::MapPair<$src, $dest> { + fn magic_field(self) -> ::core::result::Result<$dest, $crate::MappingError> { + $fn_name(self.0.take().expect("magic_map field consumed twice")) + } + } + impl LocalFieldWrap<::core::option::Option<$dest>> + for &mut &mut &mut &mut $crate::MapPair<$src, ::core::option::Option<$dest>> + { + fn magic_field( + self, + ) -> ::core::result::Result<::core::option::Option<$dest>, $crate::MappingError> + { + let src = self.0.take().expect("magic_map field consumed twice"); + ::core::result::Result::Ok(::core::option::Option::Some($fn_name(src)?)) + } + } + }; + }; +} + +#[doc(hidden)] +pub struct MapProbe(pub Option, pub core::marker::PhantomData); + +impl MapProbe { + #[doc(hidden)] + pub fn new(src: S) -> Self { + MapProbe(Some(src), core::marker::PhantomData) + } +} diff --git a/magic_map/tests/magic_map.rs b/magic_map/tests/magic_map.rs index 2807c89..9339efb 100644 --- a/magic_map/tests/magic_map.rs +++ b/magic_map/tests/magic_map.rs @@ -3,6 +3,9 @@ //! don't carry their own unit tests: asserting a generated field-by-field copy //! is restating the declaration. +// Integration tests are their own crate, so the scope lives here rather than +// in a lib.rs — same rule: once, at the crate root. +magic_map::magic_map_scope!(); use magic_map::magic_map; use magic_map::{MapFrom, MapInto, MappingError}; use rust_decimal::Decimal; @@ -739,3 +742,263 @@ mod override_question_mark { ); } } + +// ── foreign→foreign nested funnelling (magic_map_scope!) ───────────────────── +// +// 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 +// them is legal anywhere. + +mod scoped { + pub mod db { + #[derive(magic_map::MagicMap, Clone)] + pub struct State { + pub name: String, + } + #[derive(magic_map::MagicMap, Clone)] + pub struct Locality { + pub name: String, + } + #[derive(magic_map::MagicMap, Clone)] + pub struct PostalCode { + pub code: String, + pub state: State, + pub locality: Option, + pub neighborhoods: Vec, + pub population: i32, + } + } + + pub mod dtos { + #[derive(magic_map::MagicMap, Debug, PartialEq)] + pub struct StateResponse { + pub name: String, + } + #[derive(magic_map::MagicMap, Debug, PartialEq)] + pub struct LocalityResponse { + pub name: String, + } + #[derive(magic_map::MagicMap, Debug, PartialEq)] + pub struct PostalCodeResponse { + pub code: String, + pub state: StateResponse, + pub locality: Option, + pub neighborhoods: Vec, + pub population: i64, // widening leaf still funnels + } + } + + // A neutral "service" module: owns neither type. + pub mod mappers { + use super::{db, dtos}; + use magic_map::magic_map; + + magic_map!(pub fn state_to_dto: db::State => dtos::StateResponse); + magic_map!(pub fn locality_to_dto: db::Locality => dtos::LocalityResponse); + + // Nested `State`, `Option`, `Vec` and the i32→i64 + // widening all auto-fill — no overrides, no hand-written recursion. + magic_map!(pub fn postal_code_to_dto: db::PostalCode => dtos::PostalCodeResponse); + } +} + +#[test] +fn fn_form_funnels_nested_foreign_pairs() { + use scoped::{db, dtos, mappers}; + + let src = db::PostalCode { + code: "97000".into(), + state: db::State { + name: "Yucatán".into(), + }, + locality: Some(db::Locality { + name: "Mérida".into(), + }), + neighborhoods: vec![ + db::Locality { + name: "Centro".into(), + }, + db::Locality { + name: "Itzimná".into(), + }, + ], + population: 921_770, + }; + + let dto = mappers::postal_code_to_dto(src).unwrap(); + assert_eq!(dto.code, "97000"); + assert_eq!(dto.state.name, "Yucatán"); + assert_eq!( + dto.locality, + Some(dtos::LocalityResponse { + name: "Mérida".into() + }) + ); + assert_eq!(dto.neighborhoods.len(), 2); + assert_eq!(dto.neighborhoods[0].name, "Centro"); + assert_eq!(dto.population, 921_770i64); +} + +#[test] +fn fn_form_none_stays_none() { + use scoped::{db, mappers}; + + let dto = mappers::postal_code_to_dto(db::PostalCode { + code: "00000".into(), + state: db::State { name: "X".into() }, + locality: None, + neighborhoods: vec![], + population: 0, + }) + .unwrap(); + assert_eq!(dto.locality, None); + assert!(dto.neighborhoods.is_empty()); +} + +/// Leaves are found without being declared. Tier 1 of the probe holds only +/// the concrete pairs the fn form emitted, so none of these has a candidate +/// there and every one falls through to the `MapFrom` blanket. A regression +/// that made tier 1 blanket-shaped would match these structurally and fail on +/// the bound instead — which is exactly the bug this guards. +#[test] +fn leaves_resolve_through_the_probe_without_registration() { + use __magic_map_scope::{ProbeGlobal as _, ProbeLocal as _}; + use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc}; + + macro_rules! probe { + ($src:expr, $ty:ty) => {{ + let out: $ty = (&mut &mut magic_map::MapProbe::new($src)) + .magic_probe() + .expect("leaf must resolve without registration"); + out + }}; + } + + probe!(true, bool); + probe!('x', char); + probe!(1i8, i8); + probe!(1i16, i16); + probe!(1i32, i32); + probe!(1i64, i64); + probe!(1i128, i128); + probe!(1isize, isize); + probe!(1u8, u8); + probe!(1u16, u16); + probe!(1u32, u32); + probe!(1u64, u64); + probe!(1u128, u128); + probe!(1usize, usize); + probe!(1f32, f32); + probe!(1f64, f64); + probe!(String::from("s"), String); + + // widenings + probe!(1u8, u16); + probe!(1i32, i64); + probe!(1f32, f64); + + // feature leaves + let id = Uuid::from_u128(0x0192_3f4b_5c6d_7e8f_9012_3456_789a_bcde); + probe!(id, Uuid); + probe!(id, String); + probe!(id.to_string(), Uuid); + + let d = Decimal::new(1995, 2); + probe!(d, Decimal); + probe!(d, f64); + probe!(d, String); + probe!(String::from("19.95"), Decimal); + probe!(19.95f64, Decimal); + + let now: DateTime = Utc::now(); + probe!(now, DateTime); + probe!(now, String); + probe!(now.to_rfc3339(), DateTime); + let day = NaiveDate::from_ymd_opt(2026, 8, 9).unwrap(); + probe!(day, NaiveDate); + probe!(day, String); + probe!(day.to_string(), NaiveDate); + probe!(now.naive_utc(), NaiveDateTime); + probe!(now.time(), NaiveTime); + + probe!(serde_json::Value::Null, serde_json::Value); + + // wrappers over leaves, still tier 2 + probe!(vec![1i32, 2i32], Vec); + probe!(Some(id), Option); + + // …and a declared pair resolves through tier 1 in the very same scope, so + // the fallthrough above is a real fallthrough, not tier 1 being absent. + let state: scoped::dtos::StateResponse = + (&mut &mut magic_map::MapProbe::new(scoped::db::State { + name: "Yucatán".into(), + })) + .magic_probe() + .expect("declared pair must resolve through tier 1"); + assert_eq!(state.name, "Yucatán"); +} + +// ── custom leaves ─────────────────────────────────────────────────────────── + +mod custom_leaf { + /// A leaf declared the normal way, in the crate that owns it: `MapFrom` + /// impls the local trait cannot see until `leaves: [...]` names them. + #[derive(Clone, Copy, Debug, PartialEq)] + pub struct Celsius(pub i32); + + magic_map::map_identity!(Celsius); + + /// A generic wrapper, like `quickedge_commons::Patch`: its `MapFrom` + /// 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::MapFrom for String { + fn map_from(src: Celsius) -> Result { + Ok(format!("{}C", src.0)) + } + } +} + +mod custom_leaf_use { + use super::custom_leaf::Celsius; + use magic_map::magic_map; + + pub mod db { + #[derive(magic_map::MagicMap, Clone)] + pub struct Reading { + pub at: String, + pub temp: super::Celsius, + pub note: crate::custom_leaf::Wrap, + } + } + pub mod dtos { + #[derive(magic_map::MagicMap, Debug, PartialEq)] + pub struct ReadingResponse { + pub at: String, + pub temp: String, // Celsius → String, via the declared leaf + pub note: crate::custom_leaf::Wrap, // generic leaf + widening + } + } + + magic_map!(pub fn reading_to_dto: db::Reading => dtos::ReadingResponse); +} + +#[test] +fn custom_and_generic_leaves_need_no_declaration() { + let dto = custom_leaf_use::reading_to_dto(custom_leaf_use::db::Reading { + at: "2026-08-09".into(), + temp: custom_leaf::Celsius(21), + note: custom_leaf::Wrap(7i32), + }) + .unwrap(); + assert_eq!(dto.temp, "21C"); + assert_eq!(dto.note, custom_leaf::Wrap(7i64)); +} diff --git a/magic_map/tests/readme.rs b/magic_map/tests/readme.rs index dc2dfcb..19f3341 100644 --- a/magic_map/tests/readme.rs +++ b/magic_map/tests/readme.rs @@ -7,6 +7,10 @@ // Example types exist to be mapped, not exhaustively read back. #![allow(dead_code)] +// The fn-form examples below need the crate-local funnel; see the README's +// `magic_map_scope!` section. Once, at the crate root, no arguments. +magic_map::magic_map_scope!(); + use magic_map::{MapInto, MappingError}; // ── Quick start ────────────────────────────────────────────────────────────── @@ -488,3 +492,78 @@ fn custom_leaves() -> Result<(), MappingError> { assert_eq!(ts.to_rfc3339(), "1970-01-01T00:00:00+00:00"); Ok(()) } + +// ── magic_map_scope! — the fn form's crate-local funnel ────────────────────── +// The scope itself is declared once at the top of this file. + +mod scope_section { + use magic_map::magic_map; + + pub mod db { + #[derive(magic_map::MagicMap, Clone)] + #[magic_map(export = "ScopeState")] + pub struct State { + pub name: String, + } + #[derive(magic_map::MagicMap, Clone)] + #[magic_map(export = "ScopeLocality")] + pub struct Locality { + pub name: String, + } + #[derive(magic_map::MagicMap, Clone)] + #[magic_map(export = "ScopePostalCode")] + pub struct PostalCode { + pub code: String, + pub state: State, + pub locality: Option, + pub neighborhoods: Vec, + } + } + + pub mod dtos { + #[derive(magic_map::MagicMap, Debug)] + #[magic_map(export = "ScopeStateResponse")] + pub struct StateResponse { + pub name: String, + } + #[derive(magic_map::MagicMap, Debug)] + #[magic_map(export = "ScopeLocalityResponse")] + pub struct LocalityResponse { + pub name: String, + } + #[derive(magic_map::MagicMap, Debug)] + #[magic_map(export = "ScopePostalCodeResponse")] + pub struct PostalCodeResponse { + pub code: String, + pub state: StateResponse, + pub locality: Option, + pub neighborhoods: Vec, + } + } + + magic_map!(pub fn state_to_dto: db::State => dtos::StateResponse); + magic_map!(pub fn locality_to_dto: db::Locality => dtos::LocalityResponse); + + // nested State, Option, Vec — no overrides needed + magic_map!(pub fn postal_code_to_dto: db::PostalCode => dtos::PostalCodeResponse); +} + +#[test] +fn scope_section_nested_pairs_compose() { + let dto = scope_section::postal_code_to_dto(scope_section::db::PostalCode { + code: "97000".into(), + state: scope_section::db::State { + name: "Yucatán".into(), + }, + locality: Some(scope_section::db::Locality { + name: "Mérida".into(), + }), + neighborhoods: vec![scope_section::db::Locality { + name: "Centro".into(), + }], + }) + .unwrap(); + assert_eq!(dto.state.name, "Yucatán"); + assert_eq!(dto.locality.unwrap().name, "Mérida"); + assert_eq!(dto.neighborhoods.len(), 1); +} diff --git a/magic_map_macros/src/magic_map.rs b/magic_map_macros/src/magic_map.rs index 41557e1..e8e1489 100644 --- a/magic_map_macros/src/magic_map.rs +++ b/magic_map_macros/src/magic_map.rs @@ -473,6 +473,12 @@ pub fn expand(raw: TokenStream2, input: ExpandInput) -> Result Result Result ::core::result::Result<#dest, ::magic_map::MappingError> { #body } + + // Registers the pair in the crate-local funnel, so a *different* + // fn-form mapping can auto-fill a nested field of this type. The + // orphan rule permits it because the trait is local even when both + // types are foreign, and the impls are concrete so they never + // shadow a leaf's route to `MapFrom`. + ::magic_map::__magic_map_declare_local!( + crate::__magic_map_scope, #name, #src, #dest + ); }, None => quote! { impl ::magic_map::MapFrom<#src> for #dest {