use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct StructuredId(pub u32);
impl StructuredId {
pub fn new(category: u8, subcategory: u8, number: u16) -> Self {
assert!((1..=99).contains(&category), "category must be 1-99");
assert!(subcategory <= 99, "subcategory must be 0-99");
assert!((1..=9999).contains(&number), "number must be 1-9999");
Self(category as u32 * 1_000_000 + subcategory as u32 * 10_000 + number as u32)
}
pub fn try_new(category: u8, subcategory: u8, number: u16) -> Option<Self> {
if !(1..=99).contains(&category) || subcategory > 99 || !(1..=9999).contains(&number) {
return None;
}
Some(Self(
category as u32 * 1_000_000 + subcategory as u32 * 10_000 + number as u32,
))
}
pub fn category(&self) -> u8 {
(self.0 / 1_000_000) as u8
}
pub fn subcategory(&self) -> u8 {
((self.0 / 10_000) % 100) as u8
}
pub fn number(&self) -> u16 {
(self.0 % 10_000) as u16
}
pub fn domain(&self) -> IdDomain {
match self.category() {
10..=19 => IdDomain::Actors,
20..=29 => IdDomain::Items,
30..=39 => IdDomain::Skills,
40..=49 => IdDomain::World,
_ => IdDomain::Unknown,
}
}
pub fn to_id_string(&self) -> String {
format!("{:08}", self.0)
}
pub fn parse(s: &str) -> Option<Self> {
let val: u32 = s.parse().ok()?;
Self::from_raw(val)
}
pub fn from_raw(val: u32) -> Option<Self> {
let id = Self(val);
let cat = id.category();
let num = id.number();
if !(1..=99).contains(&cat) || !(1..=9999).contains(&num) {
return None;
}
Some(id)
}
}
impl fmt::Display for StructuredId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:08}", self.0)
}
}
impl Serialize for StructuredId {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_id_string())
}
}
impl<'de> Deserialize<'de> for StructuredId {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct IdVisitor;
impl<'de> serde::de::Visitor<'de> for IdVisitor {
type Value = StructuredId;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a structured ID string \"CCSSNNNN\" or a u32 number")
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<StructuredId, E> {
StructuredId::parse(v)
.ok_or_else(|| E::custom(format!("invalid structured ID: {v}")))
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<StructuredId, E> {
if v > u32::MAX as u64 {
return Err(E::custom(format!("structured ID too large: {v}")));
}
StructuredId::from_raw(v as u32)
.ok_or_else(|| E::custom(format!("invalid structured ID value: {v}")))
}
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<StructuredId, E> {
if v < 0 || v > u32::MAX as i64 {
return Err(E::custom(format!("structured ID out of range: {v}")));
}
self.visit_u64(v as u64)
}
}
deserializer.deserialize_any(IdVisitor)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum IdDomain {
Actors,
Items,
Skills,
World,
Unknown,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encodes_and_decodes_components() {
let id = StructuredId::new(21, 1, 23);
assert_eq!(id.0, 21_01_0023);
assert_eq!(id.category(), 21);
assert_eq!(id.subcategory(), 1);
assert_eq!(id.number(), 23);
assert_eq!(id.to_id_string(), "21010023");
}
#[test]
fn parses_and_validates_raw() {
assert_eq!(StructuredId::parse("21010023"), Some(StructuredId(21010023)));
assert!(StructuredId::from_raw(0).is_none(), "category 0 is invalid");
assert!(StructuredId::try_new(0, 0, 1).is_none());
}
#[test]
fn domain_follows_category_ranges() {
assert_eq!(StructuredId::new(11, 0, 1).domain(), IdDomain::Actors);
assert_eq!(StructuredId::new(21, 0, 1).domain(), IdDomain::Items);
assert_eq!(StructuredId::new(99, 0, 1).domain(), IdDomain::Unknown);
}
#[test]
fn serde_accepts_string_or_number() {
let from_str: StructuredId = serde_json::from_str("\"21010023\"").unwrap();
let from_num: StructuredId = serde_json::from_str("21010023").unwrap();
assert_eq!(from_str, from_num);
assert_eq!(serde_json::to_string(&from_str).unwrap(), "\"21010023\"");
}
}