herolib-sid 0.1.0

SmartID - Short, human-readable, collision-free identifiers
Documentation
//! SmartID - The core identifier type

use super::base36::{CAPACITY_6, decode, encode_padded, is_valid_sid, required_length};
use super::error::{Result, SidError};
use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;

/// A SmartID - a short, human-readable, collision-free identifier.
///
/// SmartIDs are base-36 encoded (0-9, a-z), 4-6 characters long,
/// and designed for distributed systems with up to 999 contributors.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub struct SmartId {
    #[serde(default)]
    global_id: u64,
    #[serde(default = "default_length")]
    length: usize,
}

/// Default length for SmartId (4 characters).
fn default_length() -> usize {
    4
}

impl SmartId {
    /// Create a SmartID from a global ID.
    ///
    /// The length is automatically determined based on the value.
    pub fn from_global_id(global_id: u64) -> Result<Self> {
        if global_id >= CAPACITY_6 {
            return Err(SidError::GlobalIdOverflow(global_id));
        }
        let length = required_length(global_id);
        Ok(Self { global_id, length })
    }

    /// Create a SmartID with explicit length (for maintaining space-wide consistency).
    ///
    /// Length will be at least the required length for the global_id.
    pub fn from_global_id_with_length(global_id: u64, min_length: usize) -> Result<Self> {
        if global_id >= CAPACITY_6 {
            return Err(SidError::GlobalIdOverflow(global_id));
        }
        let required = required_length(global_id);
        let length = min_length.max(required).min(6);
        Ok(Self { global_id, length })
    }

    /// Compute a SmartID using the deterministic formula.
    ///
    /// Formula: `global_id = local_counter * num_contributors + contributor_id + 1`
    ///
    /// The `+ 1` ensures that zero is never returned as an ID, making it easier
    /// to distinguish between "no ID set" and "first ID" in application code.
    pub fn compute(
        local_counter: u64,
        contributor_id: u16,
        num_contributors: u16,
        min_length: usize,
    ) -> Result<Self> {
        if contributor_id >= 999 {
            return Err(SidError::ContributorIdTooLarge(contributor_id));
        }

        let global_id = local_counter * (num_contributors as u64) + (contributor_id as u64) + 1;

        Self::from_global_id_with_length(global_id, min_length)
    }

    /// Get the numeric global ID.
    pub fn global_id(&self) -> u64 {
        self.global_id
    }

    /// Get the current display length.
    pub fn length(&self) -> usize {
        self.length
    }

    /// Get the base-36 encoded string.
    pub fn as_str(&self) -> String {
        encode_padded(self.global_id, self.length)
    }

    /// Parse from a base-36 string.
    pub fn parse(s: &str) -> Result<Self> {
        if !is_valid_sid(s) {
            return Err(SidError::InvalidFormat(s.to_string()));
        }
        let global_id = decode(s)?;
        Ok(Self {
            global_id,
            length: s.len(),
        })
    }
}

impl fmt::Display for SmartId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.as_str())
    }
}

impl FromStr for SmartId {
    type Err = SidError;

    fn from_str(s: &str) -> Result<Self> {
        Self::parse(s)
    }
}

impl PartialOrd for SmartId {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for SmartId {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        self.global_id.cmp(&other.global_id)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_from_global_id() {
        let sid = SmartId::from_global_id(0).unwrap();
        assert_eq!(sid.global_id(), 0);
        assert_eq!(sid.length(), 4);
        assert_eq!(sid.as_str(), "0000");

        let sid = SmartId::from_global_id(12).unwrap();
        assert_eq!(sid.as_str(), "000c");

        let sid = SmartId::from_global_id(1679615).unwrap();
        assert_eq!(sid.as_str(), "zzzz");
        assert_eq!(sid.length(), 4);

        let sid = SmartId::from_global_id(1679616).unwrap();
        assert_eq!(sid.as_str(), "10000");
        assert_eq!(sid.length(), 5);
    }

    #[test]
    fn test_compute() {
        // Single contributor: global_id = local_counter * num_contributors + cid + 1
        // First ID is 1, not 0 (to distinguish from "no ID set")
        let sid = SmartId::compute(0, 0, 1, 4).unwrap();
        assert_eq!(sid.global_id(), 1);
        assert_eq!(sid.as_str(), "0001");

        let sid = SmartId::compute(12, 0, 1, 4).unwrap();
        assert_eq!(sid.global_id(), 13); // 12 * 1 + 0 + 1 = 13
        assert_eq!(sid.as_str(), "000d");

        // Multiple contributors: global_id = local_counter * num_contributors + cid + 1
        let sid = SmartId::compute(12, 0, 999, 4).unwrap();
        assert_eq!(sid.global_id(), 12 * 999 + 0 + 1);
        assert_eq!(sid.global_id(), 11989);

        let sid = SmartId::compute(12, 1, 999, 4).unwrap();
        assert_eq!(sid.global_id(), 12 * 999 + 1 + 1);
        assert_eq!(sid.global_id(), 11990);

        let sid = SmartId::compute(12, 998, 999, 4).unwrap();
        assert_eq!(sid.global_id(), 12 * 999 + 998 + 1);
        assert_eq!(sid.global_id(), 12987);
    }

    #[test]
    fn test_parse() {
        let sid = SmartId::parse("0000").unwrap();
        assert_eq!(sid.global_id(), 0);
        assert_eq!(sid.length(), 4);

        let sid = SmartId::parse("0990").unwrap();
        assert_eq!(sid.global_id(), 11988);

        let sid = SmartId::parse("zzzz").unwrap();
        assert_eq!(sid.global_id(), 1679615);

        let sid = SmartId::parse("10000").unwrap();
        assert_eq!(sid.global_id(), 1679616);
        assert_eq!(sid.length(), 5);
    }

    #[test]
    fn test_parse_invalid() {
        assert!(SmartId::parse("abc").is_err()); // too short
        assert!(SmartId::parse("abcdefg").is_err()); // too long
        assert!(SmartId::parse("ABCD").is_err()); // uppercase
        assert!(SmartId::parse("ab-d").is_err()); // invalid char
    }

    #[test]
    fn test_roundtrip() {
        for global_id in [0, 12, 35, 1000, 11988, 1679615, 1679616, 60466175] {
            let sid = SmartId::from_global_id(global_id).unwrap();
            let s = sid.as_str();
            let parsed = SmartId::parse(&s).unwrap();
            assert_eq!(parsed.global_id(), global_id);
        }
    }

    #[test]
    fn test_display() {
        let sid = SmartId::from_global_id(12).unwrap();
        assert_eq!(format!("{}", sid), "000c");
    }

    #[test]
    fn test_from_str() {
        let sid: SmartId = "0990".parse().unwrap();
        assert_eq!(sid.global_id(), 11988);
    }

    #[test]
    fn test_ordering() {
        let a = SmartId::from_global_id(100).unwrap();
        let b = SmartId::from_global_id(200).unwrap();
        let c = SmartId::from_global_id(100).unwrap();

        assert!(a < b);
        assert!(b > a);
        assert_eq!(a, c);
    }

    #[test]
    fn test_serde() {
        let sid = SmartId::from_global_id(11988).unwrap();
        let json = serde_json::to_string(&sid).unwrap();
        let parsed: SmartId = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed, sid);
    }

    #[test]
    fn test_contributor_id_too_large() {
        let result = SmartId::compute(0, 999, 1000, 4);
        assert!(matches!(result, Err(SidError::ContributorIdTooLarge(999))));
    }
}