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 {