Skip to content

← Shape Your Data: Structs, Enums, Pattern Matching step 23 of 26

Medium Primitives

Discriminants, #[repr(u8)], as casts and mem::discriminant

Decode wire-format log-level bytes.

pub fn decode_levels(codes: Vec<u8>) -> Vec<String>

The protocol assigns Trace = 10, Debug = 20, Info = 30, Warn (which takes the next value up from Info), Error = 50. A recognised code renders as "{Name}={code}"; anything else renders as "?={code}".

[10, 31, 40] -> ["Trace=10", "Warn=31", "?=40"]

Discriminants: the bridge to the wire

A fieldless enum is, at the machine level, an integer. #[repr(u8)] pins that integer to one byte and lets you choose the values:

#[repr(u8)]
enum Lvl {
    Trace = 10,
    Debug = 20,
    Info  = 30,
    Warn,          // 31 — implicit values continue from the last explicit one
    Error = 50,
}

Warn is 31. Implicit discriminants continue from the previous explicit one, they do not restart at zero. Two variants ending up with the same value is E0081. Putting #[repr(u8)] somewhere it does not belong is E0517.

Getting the number out is a cast: Lvl::Info as u8 is 30. That gives you a real, C-compatible integer you can put in a byte buffer, hand to an FFI function, or compare against a protocol constant.

The one-way street

Now try to go back:

let level = code as Lvl;
error[E0605]: non-primitive cast: `u8` as `Lvl`

The starter ships that error, and the asymmetry is the whole lesson. Casting to an integer is always safe: every enum value has a discriminant. Casting from an integer is not: 40 is not a Lvl, and a language that let you produce one anyway would have handed you a value the exhaustiveness checker had already promised could not exist. Every match in your program would be a lie.

So there is no derive for this. You write the table by hand:

fn from_u8(code: u8) -> Option<Self> {
    match code {
        10 => Some(Self::Trace),
        // ...
        _ => None,
    }
}

The Option in the return type is the point. Decoding an untrusted byte is a fallible operation and the signature says so. In real projects people reach for a derive macro crate to generate this; in this course there are no crates, and writing it once is more instructive anyway.

Data-carrying enums are not integers at all

enum Data { A(u8), B }
Data::A(1) as u8      // E0605, non-primitive cast

The cast is only defined for enums where every variant is fieldless. A Data::A(1) is a tag plus a payload; there is no integer it “is”. That restriction is exactly why data-carrying enums are safe to match on.

If what you want is “are these the same variant, ignoring payloads”, the standard library has it:

use std::mem::discriminant;
discriminant(&Data::A(1)) == discriminant(&Data::A(9))   // true

mem::discriminant returns an opaque, comparable token. You cannot turn it into a number, and that is deliberate — it is for comparison, not for the wire.

Lints in the area

cast_possible_truncation and as_conversions (both allow-by-default, pedantic/restriction) exist because as between integer types silently truncates. as is the blunt instrument; u32::try_from(x) is the one that tells you when it did not fit. This course’s later tracks lean on try_from; enum-to-integer is one of the few places as is unambiguously the right tool.

manual_range_patterns still applies to the match in from_u8 if your codes happen to be contiguous.

Remember the grade is compile + tests + clippy -D warnings.