Skip to main content

concinnity_asset/
id.rs

1// Dense u32 asset identity.
2//
3// Asset names declared in a world are resolved to an `AssetId` at build time
4// (via the installed name resolver, see `resolver`); the compiled blob and the
5// runtime carry only the integer, so every cross-reference lookup is an integer
6// compare, not a string compare.
7//
8// `AssetId` serializes as a bare `u32` in every format. It deserializes from a
9// `u32` in non-self-describing formats (postcard, the blob defs table) and from
10// either an integer or a name string in human-readable formats (JSON, the
11// compiled `args_bytes`): an integer is an already-resolved id, a name string is
12// run through the installed resolver.
13
14use core::fmt;
15
16use alloc::format;
17use serde::de::{self, Visitor};
18use serde::{Deserialize, Deserializer, Serialize, Serializer};
19
20use crate::resolver::resolve_name;
21
22/// A dense integer handle for one asset, assigned at build time in world
23/// declaration order. Equality and hashing are integer ops.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
25pub struct AssetId(pub u32);
26
27impl fmt::Display for AssetId {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        write!(f, "#{}", self.0)
30    }
31}
32
33impl Serialize for AssetId {
34    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
35        s.serialize_u32(self.0)
36    }
37}
38
39struct AssetIdVisitor;
40
41impl Visitor<'_> for AssetIdVisitor {
42    type Value = AssetId;
43
44    fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        f.write_str("an asset id integer or a name string")
46    }
47
48    fn visit_u64<E: de::Error>(self, v: u64) -> Result<AssetId, E> {
49        Ok(AssetId(v as u32))
50    }
51    fn visit_i64<E: de::Error>(self, v: i64) -> Result<AssetId, E> {
52        Ok(AssetId(v as u32))
53    }
54    fn visit_str<E: de::Error>(self, v: &str) -> Result<AssetId, E> {
55        resolve_name(v).map(AssetId).ok_or_else(|| {
56            E::custom(format!(
57                "no asset-name resolver installed to resolve reference {v:?}"
58            ))
59        })
60    }
61    fn visit_string<E: de::Error>(self, v: alloc::string::String) -> Result<AssetId, E> {
62        self.visit_str(&v)
63    }
64}
65
66impl<'de> Deserialize<'de> for AssetId {
67    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
68        if d.is_human_readable() {
69            d.deserialize_any(AssetIdVisitor)
70        } else {
71            Ok(AssetId(u32::deserialize(d)?))
72        }
73    }
74}
75
76/// `serde` `deserialize_with` helper for an optional cross-reference field.
77///
78/// Accepts a name string (resolved), an integer id, an empty string, or null;
79/// the latter two resolve to `None`. Apply with `#[serde(default,
80/// deserialize_with = "concinnity_asset::de_opt_asset_ref")]` so a missing field
81/// is also `None`.
82pub fn de_opt_asset_ref<'de, D>(d: D) -> Result<Option<AssetId>, D::Error>
83where
84    D: Deserializer<'de>,
85{
86    // A non-self-describing format (postcard, the baked blob form) carries the
87    // already-resolved id; names only appear in human-readable input.
88    if !d.is_human_readable() {
89        return Option::<AssetId>::deserialize(d);
90    }
91
92    struct OptVisitor;
93
94    impl Visitor<'_> for OptVisitor {
95        type Value = Option<AssetId>;
96
97        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98            f.write_str("an asset reference name string, id integer, or null")
99        }
100
101        fn visit_unit<E: de::Error>(self) -> Result<Option<AssetId>, E> {
102            Ok(None)
103        }
104        fn visit_none<E: de::Error>(self) -> Result<Option<AssetId>, E> {
105            Ok(None)
106        }
107        fn visit_u64<E: de::Error>(self, v: u64) -> Result<Option<AssetId>, E> {
108            Ok(Some(AssetId(v as u32)))
109        }
110        fn visit_i64<E: de::Error>(self, v: i64) -> Result<Option<AssetId>, E> {
111            Ok(Some(AssetId(v as u32)))
112        }
113        fn visit_str<E: de::Error>(self, v: &str) -> Result<Option<AssetId>, E> {
114            if v.is_empty() {
115                Ok(None)
116            } else {
117                AssetIdVisitor.visit_str(v).map(Some)
118            }
119        }
120        fn visit_string<E: de::Error>(
121            self,
122            v: alloc::string::String,
123        ) -> Result<Option<AssetId>, E> {
124            self.visit_str(&v)
125        }
126    }
127
128    d.deserialize_any(OptVisitor)
129}
130
131#[cfg(test)]
132mod tests {
133    use alloc::string::ToString;
134
135    use super::*;
136
137    #[test]
138    fn round_trips_through_json_as_a_bare_integer() {
139        let bytes = serde_json::to_vec(&AssetId(7)).unwrap();
140        assert_eq!(bytes, b"7");
141        let back: AssetId = serde_json::from_slice(&bytes).unwrap();
142        assert_eq!(back, AssetId(7));
143    }
144
145    #[test]
146    fn deserializes_from_an_already_resolved_integer() {
147        // The compiled-args / runtime path: refs are ints, no resolver needed.
148        let id: AssetId = serde_json::from_str("5").unwrap();
149        assert_eq!(id, AssetId(5));
150    }
151
152    #[test]
153    fn round_trips_through_postcard() {
154        // postcard is the blob defs-table format (BlobAssetDef.name is an AssetId).
155        let bytes = postcard::to_allocvec(&AssetId(1234)).unwrap();
156        let back: AssetId = postcard::from_bytes(&bytes).unwrap();
157        assert_eq!(back, AssetId(1234));
158    }
159
160    #[test]
161    fn a_truncated_baked_id_is_an_error_not_a_panic() {
162        // A blob whose defs table is cut short must surface as a decode error:
163        // the baked path reads the id straight through with no visitor to
164        // fall back on.
165        assert!(postcard::from_bytes::<AssetId>(&[]).is_err());
166    }
167
168    #[test]
169    fn opt_ref_treats_empty_null_and_missing_as_none() {
170        #[derive(serde::Deserialize)]
171        struct Holder {
172            #[serde(default, deserialize_with = "de_opt_asset_ref")]
173            r: Option<AssetId>,
174        }
175        assert!(
176            serde_json::from_str::<Holder>("{\"r\":\"\"}")
177                .unwrap()
178                .r
179                .is_none()
180        );
181        assert!(
182            serde_json::from_str::<Holder>("{\"r\":null}")
183                .unwrap()
184                .r
185                .is_none()
186        );
187        assert!(serde_json::from_str::<Holder>("{}").unwrap().r.is_none());
188        assert_eq!(
189            serde_json::from_str::<Holder>("{\"r\":5}").unwrap().r,
190            Some(AssetId(5))
191        );
192    }
193
194    #[test]
195    fn opt_ref_round_trips_through_postcard() {
196        // The baked blob form: not self-describing, carries the resolved id.
197        #[derive(serde::Serialize, serde::Deserialize)]
198        struct Holder {
199            #[serde(default, deserialize_with = "de_opt_asset_ref")]
200            r: Option<AssetId>,
201            #[serde(default, deserialize_with = "de_opt_asset_ref")]
202            none: Option<AssetId>,
203        }
204        let h = Holder {
205            r: Some(AssetId(7)),
206            none: None,
207        };
208        let bytes = postcard::to_allocvec(&h).unwrap();
209        let back: Holder = postcard::from_bytes(&bytes).unwrap();
210        assert_eq!(back.r, Some(AssetId(7)));
211        assert_eq!(back.none, None);
212    }
213
214    #[test]
215    fn display_formats_with_a_hash_prefix() {
216        assert_eq!(AssetId(42).to_string(), "#42");
217    }
218
219    #[test]
220    fn default_is_zero() {
221        assert_eq!(AssetId::default(), AssetId(0));
222    }
223
224    #[test]
225    fn deserializes_a_name_through_the_seam() {
226        crate::test_support::install_resolvers();
227        assert_eq!(
228            serde_json::from_str::<AssetId>("\"floor\"").unwrap(),
229            AssetId(5)
230        );
231        // An owned string, the form the serde_json::Value bridge hands over.
232        assert_eq!(
233            serde_json::from_value::<AssetId>(serde_json::json!("wall")).unwrap(),
234            AssetId(4)
235        );
236    }
237
238    #[test]
239    fn deserializes_a_signed_integer_narrowed_to_id_width() {
240        assert_eq!(
241            serde_json::from_str::<AssetId>("-1").unwrap(),
242            AssetId(u32::MAX)
243        );
244    }
245
246    #[test]
247    fn a_wrong_typed_id_names_what_it_accepts() {
248        let err = serde_json::from_str::<AssetId>("true")
249            .unwrap_err()
250            .to_string();
251        assert!(
252            err.contains("an asset id integer or a name string"),
253            "{err}"
254        );
255    }
256
257    #[derive(Debug, serde::Deserialize)]
258    struct Holder {
259        #[serde(default, deserialize_with = "de_opt_asset_ref")]
260        r: Option<AssetId>,
261    }
262
263    #[test]
264    fn opt_ref_accepts_names_signed_integers_and_a_reported_none() {
265        crate::test_support::install_resolvers();
266        assert_eq!(
267            serde_json::from_str::<Holder>("{\"r\":\"floor\"}")
268                .unwrap()
269                .r,
270            Some(AssetId(5))
271        );
272        assert_eq!(
273            serde_json::from_str::<Holder>("{\"r\":-1}").unwrap().r,
274            Some(AssetId(u32::MAX))
275        );
276        // An owned string, empty or not, through the serde_json::Value bridge.
277        assert_eq!(
278            serde_json::from_value::<Holder>(serde_json::json!({"r": "wall"}))
279                .unwrap()
280                .r,
281            Some(AssetId(4))
282        );
283        assert_eq!(
284            serde_json::from_value::<Holder>(serde_json::json!({"r": ""}))
285                .unwrap()
286                .r,
287            None
288        );
289        // A `None` reported by an option-aware format, rather than a null unit.
290        assert_eq!(
291            de_opt_asset_ref(crate::test_support::NoneDeserializer).unwrap(),
292            None
293        );
294    }
295
296    #[test]
297    fn a_wrong_typed_opt_ref_names_what_it_accepts() {
298        let err = serde_json::from_str::<Holder>("{\"r\":true}")
299            .unwrap_err()
300            .to_string();
301        assert!(
302            err.contains("an asset reference name string, id integer, or null"),
303            "{err}"
304        );
305    }
306}