Skip to main content

polyoxide_venue/
key.rs

1//! Market keys: one canonical name per instrument, whichever venue lists it.
2
3use std::borrow::Cow;
4use std::error::Error;
5use std::fmt;
6use std::str::FromStr;
7use std::sync::Arc;
8
9use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
10
11use crate::{Class, Classify};
12
13/// Whether `id` is a venue or product id: an ASCII lowercase letter, then
14/// lowercase letters, digits and `-`, the form a crate's registration declares.
15const fn is_id(id: &str) -> bool {
16    let bytes = id.as_bytes();
17    if bytes.is_empty() || !bytes[0].is_ascii_lowercase() {
18        return false;
19    }
20    let mut i = 1;
21    while i < bytes.len() {
22        let b = bytes[i];
23        if !(b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') {
24            return false;
25        }
26        i += 1;
27    }
28    true
29}
30
31macro_rules! id_type {
32    ($(#[$doc:meta])* $name:ident, $what:literal) => {
33        $(#[$doc])*
34        #[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
35        pub struct $name(Cow<'static, str>);
36
37        impl $name {
38            #[doc = concat!("A ", $what, " id known when the program is built.")]
39            ///
40            /// # Panics
41            ///
42            /// Unless `id` is an ASCII lowercase letter followed by lowercase
43            /// letters, digits and `-`. In a `const` the panic is a build error.
44            pub const fn from_static(id: &'static str) -> Self {
45                assert!(
46                    is_id(id),
47                    "an id is a lowercase letter, then lowercase letters, digits and `-`"
48                );
49                Self(Cow::Borrowed(id))
50            }
51
52            #[doc = concat!("The ", $what, " id.")]
53            pub fn as_str(&self) -> &str {
54                &self.0
55            }
56        }
57
58        impl fmt::Display for $name {
59            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60                f.write_str(&self.0)
61            }
62        }
63
64        impl fmt::Debug for $name {
65            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66                f.debug_tuple(stringify!($name)).field(&self.as_str()).finish()
67            }
68        }
69    };
70}
71
72id_type! {
73    /// A venue's id: the `venue` of `venue.product:id`.
74    ///
75    /// A venue crate declares its own with [`VenueId::from_static`], equal to
76    /// the `venue` its registration declares.
77    VenueId, "venue"
78}
79
80id_type! {
81    /// A product's id: the `product` of `venue.product:id`, naming the module
82    /// that trades the instrument.
83    ///
84    /// A venue crate declares each with [`ProductId::from_static`], equal to one
85    /// of the `products` its registration declares.
86    ProductId, "product"
87}
88
89/// What a product trades, which fixes what its shared records mean.
90///
91/// A product declares its class as [`KeySpace::CLASS`], and every key it makes
92/// carries it, so [`MarketKey::class`] answers without a request. A class is
93/// added only when a product of that class lands.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
95#[non_exhaustive]
96pub enum ProductClass {
97    /// Contracts on an event's outcomes. A key names one outcome, a price lies
98    /// between 0 and 1, and a share pays one unit of the settlement currency
99    /// when its outcome happens, so a position's size is never negative.
100    EventContract,
101    /// Perpetual futures. A price is unbounded, a position's size is signed,
102    /// and holders pay or receive funding.
103    PerpetualFuture,
104    /// Buying and selling the asset itself. A price is unbounded, a position
105    /// is a balance of the base asset and so never negative, and there is no
106    /// funding.
107    Spot,
108}
109
110impl ProductClass {
111    /// The class's id, as a crate's registration spells it in `classes`.
112    pub fn as_str(self) -> &'static str {
113        match self {
114            Self::EventContract => "event-contract",
115            Self::PerpetualFuture => "perpetual-future",
116            Self::Spot => "spot",
117        }
118    }
119}
120
121impl fmt::Display for ProductClass {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        f.write_str(self.as_str())
124    }
125}
126
127/// One instrument's name, written `venue.product:id`, such as
128/// `acme.perp:ABCUSD`.
129///
130/// A key has no public constructor from text or from parts. Only a product's
131/// [`KeySpace`] makes one, and it canonicalizes the id first, so two keys are
132/// equal exactly when they name the same instrument. On an event-contract
133/// venue a key names one outcome, not a market.
134///
135/// It serializes as its text form. To read one back, parse a [`RawKey`] and
136/// hand it to the product's constructors, which canonicalize it again.
137///
138/// ```
139/// use std::borrow::Cow;
140/// use polyoxide_venue::{KeySpace, MarketKey, ProductClass, ProductId, RawKey, VenueId};
141///
142/// /// A perpetual whose ids are ASCII and uppercased.
143/// struct Perp;
144///
145/// impl KeySpace for Perp {
146///     const VENUE: VenueId = VenueId::from_static("acme");
147///     const PRODUCT: ProductId = ProductId::from_static("perp");
148///     const CLASS: ProductClass = ProductClass::PerpetualFuture;
149///     type Id = PerpId;
150///
151///     fn parse_id(text: &str) -> Result<PerpId, String> {
152///         if text.is_ascii() {
153///             Ok(PerpId(text.to_ascii_uppercase()))
154///         } else {
155///             Err("an id is ASCII".into())
156///         }
157///     }
158///
159///     fn id_text(id: &PerpId) -> Cow<'_, str> {
160///         Cow::Borrowed(&id.0)
161///     }
162/// }
163///
164/// /// An id in canonical form: only `Perp::parse_id` makes one.
165/// struct PerpId(String);
166///
167/// let key = Perp::parse(&RawKey::parse("acme.perp:abcusd")?)?;
168/// assert_eq!(key, Perp::key(&Perp::parse_id("ABCUSD")?));
169/// assert_eq!(key.to_string(), "acme.perp:ABCUSD");
170/// assert_eq!(key.class(), ProductClass::PerpetualFuture);
171/// # Ok::<(), Box<dyn std::error::Error>>(())
172/// ```
173#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
174pub struct MarketKey {
175    venue: VenueId,
176    product: ProductId,
177    // After `product`: the class follows from the venue and product, so the
178    // derived order still sorts by venue, product and id.
179    class: ProductClass,
180    id: Arc<str>,
181}
182
183impl MarketKey {
184    /// The venue that lists the instrument.
185    pub fn venue(&self) -> &VenueId {
186        &self.venue
187    }
188
189    /// The product, the module that trades it.
190    pub fn product(&self) -> &ProductId {
191        &self.product
192    }
193
194    /// The product's class, which its [`KeySpace::CLASS`] declares. It follows
195    /// from the venue and product, so the text form does not carry it.
196    pub fn class(&self) -> ProductClass {
197        self.class
198    }
199
200    /// The id, in its product's canonical form. Opaque to everything but its
201    /// product.
202    pub fn id(&self) -> &str {
203        &self.id
204    }
205}
206
207impl fmt::Display for MarketKey {
208    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209        write!(f, "{}.{}:{}", self.venue, self.product, self.id)
210    }
211}
212
213impl fmt::Debug for MarketKey {
214    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215        f.debug_tuple("MarketKey")
216            .field(&format_args!("{self}"))
217            .finish()
218    }
219}
220
221impl Serialize for MarketKey {
222    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
223        serializer.collect_str(self)
224    }
225}
226
227/// A key's text split into venue, product and id, checked by no product yet.
228///
229/// [`RawKey::parse`] splits at the first `:`, then what comes before it at its
230/// first `.`. The id is everything after the `:`, so it may hold `:` and `.`
231/// of its own. The venue and product must be ids (an ASCII lowercase letter,
232/// then lowercase letters, digits and `-`) and the id must not be empty; the
233/// id is otherwise kept as written, since only its product knows its
234/// canonical form.
235///
236/// It serializes as its text form and deserializes through
237/// [`RawKey::parse`].
238///
239/// ```
240/// use polyoxide_venue::RawKey;
241///
242/// let raw = RawKey::parse("acme.events:FED-25DEC:yes")?;
243/// assert_eq!(raw.venue().as_str(), "acme");
244/// assert_eq!(raw.product().as_str(), "events");
245/// assert_eq!(raw.id(), "FED-25DEC:yes");
246/// assert!(RawKey::parse("acme:spot").is_err());
247/// # Ok::<(), polyoxide_venue::KeyError>(())
248/// ```
249#[derive(Clone, PartialEq, Eq, Hash)]
250pub struct RawKey {
251    venue: VenueId,
252    product: ProductId,
253    id: String,
254}
255
256impl RawKey {
257    /// Splits `text`, written `venue.product:id`.
258    ///
259    /// # Errors
260    ///
261    /// [`KeyError::Malformed`] when there is no `:`, no `.` before it, a venue
262    /// or product that is not an id, or an empty id.
263    pub fn parse(text: &str) -> Result<Self, KeyError> {
264        let malformed = |reason| KeyError::Malformed {
265            text: text.to_owned(),
266            reason,
267        };
268        let (scope, id) = text
269            .split_once(':')
270            .ok_or_else(|| malformed("no `:` before the id"))?;
271        let (venue, product) = scope
272            .split_once('.')
273            .ok_or_else(|| malformed("no `.` between the venue and the product"))?;
274        if !is_id(venue) {
275            return Err(malformed(
276                "the venue is not a lowercase letter followed by lowercase letters, digits and `-`",
277            ));
278        }
279        if !is_id(product) {
280            return Err(malformed(
281                "the product is not a lowercase letter followed by lowercase letters, digits and `-`",
282            ));
283        }
284        if id.is_empty() {
285            return Err(malformed("the id is empty"));
286        }
287        Ok(Self {
288            venue: VenueId(Cow::Owned(venue.to_owned())),
289            product: ProductId(Cow::Owned(product.to_owned())),
290            id: id.to_owned(),
291        })
292    }
293
294    /// The venue, as written.
295    pub fn venue(&self) -> &VenueId {
296        &self.venue
297    }
298
299    /// The product, as written.
300    pub fn product(&self) -> &ProductId {
301        &self.product
302    }
303
304    /// The id, as written.
305    pub fn id(&self) -> &str {
306        &self.id
307    }
308}
309
310impl FromStr for RawKey {
311    type Err = KeyError;
312
313    fn from_str(text: &str) -> Result<Self, Self::Err> {
314        Self::parse(text)
315    }
316}
317
318impl fmt::Display for RawKey {
319    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
320        write!(f, "{}.{}:{}", self.venue, self.product, self.id)
321    }
322}
323
324impl fmt::Debug for RawKey {
325    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326        f.debug_tuple("RawKey")
327            .field(&format_args!("{self}"))
328            .finish()
329    }
330}
331
332impl Serialize for RawKey {
333    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
334        serializer.collect_str(self)
335    }
336}
337
338impl<'de> Deserialize<'de> for RawKey {
339    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
340        let text = Cow::<'de, str>::deserialize(deserializer)?;
341        Self::parse(&text).map_err(de::Error::custom)
342    }
343}
344
345/// A product's keys. A venue crate implements it once for each product it
346/// trades, and its methods are the only way to make a [`MarketKey`].
347///
348/// The product's [`Id`](Self::Id) type holds an id in canonical form, so
349/// [`key`](Self::key) cannot fail, and [`parse_id`](Self::parse_id) is the
350/// one place the product checks and canonicalizes text.
351pub trait KeySpace {
352    /// The venue id, equal to the `venue` the venue crate registers.
353    const VENUE: VenueId;
354    /// The product id, one of the `products` the venue crate registers.
355    const PRODUCT: ProductId;
356    /// The product's class, equal to the one the venue crate registers for
357    /// the product in `classes`.
358    const CLASS: ProductClass;
359    /// The product's own id, in canonical form.
360    type Id;
361
362    /// Reads an id from text, canonicalizing it.
363    ///
364    /// # Errors
365    ///
366    /// Why the text is not one of the product's ids.
367    fn parse_id(text: &str) -> Result<Self::Id, String>;
368
369    /// The id's canonical text, the `id` of its key.
370    fn id_text(id: &Self::Id) -> Cow<'_, str>;
371
372    /// The key of `id`.
373    fn key(id: &Self::Id) -> MarketKey {
374        MarketKey {
375            venue: Self::VENUE,
376            product: Self::PRODUCT,
377            class: Self::CLASS,
378            id: Arc::from(Self::id_text(id).as_ref()),
379        }
380    }
381
382    /// The key `raw` names, canonicalized.
383    ///
384    /// # Errors
385    ///
386    /// [`KeyError::WrongProduct`] when `raw` names another product, and
387    /// [`KeyError::InvalidId`] when its id is not one of this product's.
388    fn parse(raw: &RawKey) -> Result<MarketKey, KeyError> {
389        id_of::<Self>(raw.venue(), raw.product(), raw.id()).map(|id| Self::key(&id))
390    }
391
392    /// The product's id that `key` names.
393    ///
394    /// # Errors
395    ///
396    /// [`KeyError::WrongProduct`] when `key` names another product.
397    fn id(key: &MarketKey) -> Result<Self::Id, KeyError> {
398        id_of::<Self>(key.venue(), key.product(), key.id())
399    }
400}
401
402/// `K`'s id for a key's parts, after checking they name `K`'s product. A free
403/// function, so no implementor can override the check behind `parse` and `id`.
404fn id_of<K: KeySpace + ?Sized>(
405    venue: &VenueId,
406    product: &ProductId,
407    id: &str,
408) -> Result<K::Id, KeyError> {
409    if venue != &K::VENUE || product != &K::PRODUCT {
410        return Err(KeyError::WrongProduct {
411            expected: format!("{}.{}", K::VENUE, K::PRODUCT),
412            found: format!("{venue}.{product}"),
413        });
414    }
415    K::parse_id(id).map_err(|reason| KeyError::InvalidId {
416        product: format!("{}.{}", K::VENUE, K::PRODUCT),
417        id: id.to_owned(),
418        reason,
419    })
420}
421
422/// Why text or a key named no instrument a product could read. Every one is
423/// an [`InvalidRequest`](Class::InvalidRequest): the caller named something
424/// no request can carry.
425#[derive(Debug, Clone, PartialEq, Eq)]
426#[non_exhaustive]
427pub enum KeyError {
428    /// The text is not `venue.product:id`.
429    Malformed {
430        /// The text, as given.
431        text: String,
432        /// What is wrong with it.
433        reason: &'static str,
434    },
435    /// No product this build enables has the key's venue and product ids.
436    UnknownProduct {
437        /// The venue id, as written.
438        venue: String,
439        /// The product id, as written.
440        product: String,
441    },
442    /// A product was handed a key of another product.
443    WrongProduct {
444        /// The product that was asked, as `venue.product`.
445        expected: String,
446        /// The key's product, as `venue.product`.
447        found: String,
448    },
449    /// The product does not read the id as one of its own.
450    InvalidId {
451        /// The product, as `venue.product`.
452        product: String,
453        /// The id, as given.
454        id: String,
455        /// Why the product refused it.
456        reason: String,
457    },
458}
459
460impl KeyError {
461    /// The error for a raw key whose venue and product no enabled product has.
462    pub fn unknown(raw: &RawKey) -> Self {
463        Self::UnknownProduct {
464            venue: raw.venue().as_str().to_owned(),
465            product: raw.product().as_str().to_owned(),
466        }
467    }
468}
469
470impl fmt::Display for KeyError {
471    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
472        match self {
473            Self::Malformed { text, reason } => {
474                write!(
475                    f,
476                    "{text:?} is not a market key (`venue.product:id`): {reason}"
477                )
478            }
479            Self::UnknownProduct { venue, product } => {
480                write!(f, "no product this build enables is {venue}.{product}")
481            }
482            Self::WrongProduct { expected, found } => {
483                write!(f, "a {found} key was given where a {expected} key belongs")
484            }
485            Self::InvalidId {
486                product,
487                id,
488                reason,
489            } => write!(f, "{id:?} is not a {product} id: {reason}"),
490        }
491    }
492}
493
494impl Error for KeyError {}
495
496/// An [`InvalidRequest`](Class::InvalidRequest), whatever the case.
497impl Classify for KeyError {
498    fn class(&self) -> Class {
499        Class::InvalidRequest
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506
507    /// Ids are ASCII; letters are uppercased.
508    struct Upper;
509
510    /// An id in canonical form: only `Upper::parse_id` makes one.
511    struct UpperId(String);
512
513    impl KeySpace for Upper {
514        const VENUE: VenueId = VenueId::from_static("acme");
515        const PRODUCT: ProductId = ProductId::from_static("spot");
516        const CLASS: ProductClass = ProductClass::EventContract;
517        type Id = UpperId;
518
519        fn parse_id(text: &str) -> Result<UpperId, String> {
520            if text.is_ascii() {
521                Ok(UpperId(text.to_ascii_uppercase()))
522            } else {
523                Err("an id is ASCII".to_owned())
524            }
525        }
526
527        fn id_text(id: &UpperId) -> Cow<'_, str> {
528            Cow::Borrowed(&id.0)
529        }
530    }
531
532    /// Ids are unsigned integers, written without leading zeros.
533    struct Numbered;
534
535    impl KeySpace for Numbered {
536        const VENUE: VenueId = VenueId::from_static("acme");
537        const PRODUCT: ProductId = ProductId::from_static("perpetual");
538        const CLASS: ProductClass = ProductClass::PerpetualFuture;
539        type Id = u64;
540
541        fn parse_id(text: &str) -> Result<u64, String> {
542            text.parse().map_err(|e| format!("{e}"))
543        }
544
545        fn id_text(id: &u64) -> Cow<'_, str> {
546            Cow::Owned(id.to_string())
547        }
548    }
549
550    /// The key of an ASCII id, canonicalized by `Upper::parse_id` first.
551    fn upper(id: &str) -> MarketKey {
552        Upper::key(&Upper::parse_id(id).unwrap())
553    }
554
555    fn raw(text: &str) -> RawKey {
556        RawKey::parse(text).unwrap()
557    }
558
559    #[test]
560    fn text_splits_at_the_first_dot_and_the_first_colon() {
561        let key = raw("acme.spot:abc");
562        assert_eq!(key.venue().as_str(), "acme");
563        assert_eq!(key.product().as_str(), "spot");
564        assert_eq!(key.id(), "abc");
565
566        let dotted = raw("acme.spot:a.b");
567        assert_eq!(dotted.product().as_str(), "spot");
568        assert_eq!(dotted.id(), "a.b");
569    }
570
571    #[test]
572    fn a_synthetic_id_holding_a_second_colon_is_kept_whole() {
573        let key = raw("acme.events:FED-25DEC:yes");
574        assert_eq!(key.id(), "FED-25DEC:yes");
575        assert_eq!(key.to_string(), "acme.events:FED-25DEC:yes");
576        let parsed = Upper::parse(&raw("acme.spot:fed-25dec:yes")).unwrap();
577        assert_eq!(parsed.id(), "FED-25DEC:YES");
578    }
579
580    #[test]
581    fn malformed_text_names_its_fault() {
582        for (text, fault) in [
583            ("acme-spot-abc", "no `:`"),
584            ("acme:abc", "no `.`"),
585            ("Acme.spot:abc", "the venue"),
586            (".spot:abc", "the venue"),
587            ("acme.Spot:abc", "the product"),
588            ("acme.sp.ot:abc", "the product"),
589            ("acme.:abc", "the product"),
590            ("acme.spot:", "the id is empty"),
591            (" acme.spot:abc", "the venue"),
592        ] {
593            let err = RawKey::parse(text).unwrap_err();
594            assert!(
595                matches!(&err, KeyError::Malformed { text: t, reason } if t == text && reason.contains(fault)),
596                "{text:?}: {err}"
597            );
598        }
599    }
600
601    #[test]
602    fn a_venue_and_a_product_may_hold_digits_and_hyphens() {
603        let key = raw("acme-2.spot-x:a");
604        assert_eq!(key.venue().as_str(), "acme-2");
605        assert_eq!(key.product().as_str(), "spot-x");
606        assert_eq!(key.id(), "a");
607    }
608
609    #[test]
610    fn case_canonicalization_makes_two_spellings_one_key() {
611        let lower = Upper::parse(&raw("acme.spot:abcusd")).unwrap();
612        let upper = Upper::parse(&raw("acme.spot:ABCUSD")).unwrap();
613        assert_eq!(lower, upper);
614        assert_eq!(lower.id(), "ABCUSD");
615        let mut map = std::collections::HashMap::new();
616        map.insert(lower, 1);
617        assert_eq!(map.get(&upper), Some(&1));
618    }
619
620    #[test]
621    fn a_numbered_id_loses_its_leading_zeros() {
622        let key = Numbered::parse(&raw("acme.perpetual:007")).unwrap();
623        assert_eq!(key, Numbered::key(&7));
624        assert_eq!(key.id(), "7");
625        assert_eq!(Numbered::id(&key), Ok(7));
626    }
627
628    #[test]
629    fn a_product_refuses_another_products_key_and_an_id_it_cannot_read() {
630        let spot = upper("ABC");
631        assert_eq!(
632            Numbered::id(&spot),
633            Err(KeyError::WrongProduct {
634                expected: "acme.perpetual".to_owned(),
635                found: "acme.spot".to_owned(),
636            })
637        );
638        assert!(matches!(
639            Numbered::parse(&raw("other.perpetual:7")),
640            Err(KeyError::WrongProduct { .. })
641        ));
642        let err = Numbered::parse(&raw("acme.perpetual:seven")).unwrap_err();
643        assert!(
644            matches!(&err, KeyError::InvalidId { product, id, .. } if product == "acme.perpetual" && id == "seven"),
645            "{err}"
646        );
647        assert_eq!(err.class(), Class::InvalidRequest);
648    }
649
650    #[test]
651    fn a_key_and_a_raw_key_serialize_as_their_text() {
652        let key = upper("ABC");
653        assert_eq!(serde_json::to_string(&key).unwrap(), r#""acme.spot:ABC""#);
654        let raw: RawKey = serde_json::from_str(r#""acme.spot:abc""#).unwrap();
655        assert_eq!(raw, RawKey::parse("acme.spot:abc").unwrap());
656        assert_eq!(serde_json::to_string(&raw).unwrap(), r#""acme.spot:abc""#);
657        assert!(serde_json::from_str::<RawKey>(r#""acme-spot""#).is_err());
658        assert_eq!(Upper::parse(&raw).unwrap(), key);
659    }
660
661    #[test]
662    fn debug_shows_the_text_form() {
663        let key = upper("ABC");
664        assert_eq!(format!("{key:?}"), "MarketKey(acme.spot:ABC)");
665        assert_eq!(
666            format!("{:?}", raw("acme.spot:abc")),
667            "RawKey(acme.spot:abc)"
668        );
669        assert_eq!(format!("{:?}", Upper::VENUE), r#"VenueId("acme")"#);
670    }
671
672    #[test]
673    fn every_key_error_is_an_invalid_request() {
674        let errors = [
675            RawKey::parse("x").unwrap_err(),
676            KeyError::unknown(&raw("acme.spot:abc")),
677            Numbered::id(&upper("A")).unwrap_err(),
678            Numbered::parse(&raw("acme.perpetual:x")).unwrap_err(),
679        ];
680        for err in errors {
681            assert_eq!(err.class(), Class::InvalidRequest, "{err}");
682            assert!(err.is_fault());
683            assert!(!err.is_retriable());
684        }
685    }
686
687    #[test]
688    #[should_panic(expected = "an id is a lowercase letter")]
689    fn a_static_id_must_be_an_id() {
690        let _ = VenueId::from_static("Acme");
691    }
692
693    #[test]
694    fn a_key_carries_the_class_its_product_declares() {
695        assert_eq!(upper("ABC").class(), ProductClass::EventContract);
696        let parsed = Numbered::parse(&raw("acme.perpetual:007")).unwrap();
697        assert_eq!(parsed.class(), ProductClass::PerpetualFuture);
698        assert_eq!(parsed.class(), Numbered::CLASS);
699        // The class is not part of the text form.
700        assert_eq!(parsed.to_string(), "acme.perpetual:7");
701    }
702
703    #[test]
704    fn a_class_is_spelled_as_a_registration_spells_it() {
705        assert_eq!(ProductClass::EventContract.as_str(), "event-contract");
706        assert_eq!(
707            ProductClass::PerpetualFuture.to_string(),
708            "perpetual-future"
709        );
710    }
711}