bento-kit 0.1.1

A bento box of common Rust utilities: id generation, timing, masking
Documentation
//! Session / user / DB-key ID helpers.
//!
//! Direct ports of the corresponding functions in
//! [`du-node-utils/lib/id.js`](https://github.com/imcooder/du-node-utils/blob/main/lib/id.js):
//! `setSessionPrefix`, `generateSessionId`, `makeUidPostfix`,
//! `makeUserId`, `parseUserid`, `makeDbKey`.

use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};

use rand::Rng;

use super::random::generate_random_string;

const SESSION_PREFIX_DEFAULT_LEN: usize = 12;
const SESSION_ID_RANDOM_SUFFIX_LEN: usize = 5;

fn session_prefix_storage() -> &'static Mutex<String> {
    static PREFIX: OnceLock<Mutex<String>> = OnceLock::new();
    PREFIX.get_or_init(|| Mutex::new(generate_random_string(SESSION_PREFIX_DEFAULT_LEN)))
}

/// Set a custom session prefix used by [`generate_session_id`].
///
/// Empty inputs are ignored (matching the Node library, which silently
/// drops falsy / non-string values). Once set, the prefix persists for
/// the rest of the process.
///
/// ```
/// use bento_kit::id::{generate_session_id, set_session_prefix};
/// set_session_prefix("myapp");
/// let sid = generate_session_id();
/// assert!(sid.starts_with("myapp"));
/// ```
pub fn set_session_prefix(prefix: &str) {
    if prefix.is_empty() {
        return;
    }
    let mut guard = session_prefix_storage()
        .lock()
        .expect("session-prefix mutex poisoned");
    *guard = prefix.to_string();
}

/// Generate a session ID in the same format as Node's `generateSessionId`:
/// `<prefix><13-digit ms timestamp><5-char base36 suffix>`.
///
/// The prefix is set via [`set_session_prefix`]; before any explicit set,
/// it defaults to a random 12-char base36 string generated once per
/// process.
pub fn generate_session_id() -> String {
    let prefix = session_prefix_storage()
        .lock()
        .expect("session-prefix mutex poisoned")
        .clone();
    let ms = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis())
        .unwrap_or(0);
    let suffix = generate_random_string(SESSION_ID_RANDOM_SUFFIX_LEN);
    format!("{prefix}{ms}{suffix}")
}

/// Generate a UID postfix: hex of `(unix_ms * 1000 + random_in_0..1000)`.
///
/// Direct port of `id.makeUidPostfix()`.
///
/// ```
/// let p = bento_kit::id::make_uid_postfix();
/// assert!(p.chars().all(|c| c.is_ascii_hexdigit()));
/// ```
pub fn make_uid_postfix() -> String {
    let ms: u128 = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis())
        .unwrap_or(0);
    let r: u128 = rand::thread_rng().gen_range(0..1000);
    format!("{:x}", ms * 1000 + r)
}

/// Build a user ID in the form `connect.<appid>.<uid>.<cuid>`.
///
/// Direct port of `id.makeUserId(appid, uid, cuid)`.
pub fn make_user_id(appid: &str, uid: &str, cuid: &str) -> String {
    format!("connect.{appid}.{uid}.{cuid}")
}

/// Build a database key in the form `connect.<uid>`.
///
/// Direct port of `id.makeDbKey(uid)`.
pub fn make_db_key(uid: &str) -> String {
    format!("connect.{uid}")
}

/// Result of [`parse_user_id`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserId {
    /// The full input string.
    pub id: String,
    /// Application ID component (second segment).
    pub appid: String,
    /// User ID component (third segment).
    pub uid: String,
    /// Client ID component (fourth segment).
    pub cuid: String,
}

/// Parse a user ID produced by [`make_user_id`]. Returns `None` if the
/// string has fewer than four `.`-separated segments.
///
/// Mirrors `id.parseUserid(userId)`.
///
/// ```
/// use bento_kit::id::parse_user_id;
/// let parsed = parse_user_id("connect.app1.user42.client99").unwrap();
/// assert_eq!(parsed.appid, "app1");
/// assert_eq!(parsed.uid, "user42");
/// assert_eq!(parsed.cuid, "client99");
/// ```
pub fn parse_user_id(user_id: &str) -> Option<UserId> {
    let items: Vec<&str> = user_id.split('.').collect();
    if items.len() < 4 {
        return None;
    }
    Some(UserId {
        id: user_id.to_string(),
        appid: items[1].to_string(),
        uid: items[2].to_string(),
        cuid: items[3].to_string(),
    })
}

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

    // The session prefix is process-global, so any tests that touch
    // `set_session_prefix` must run serially against each other. cargo
    // runs tests in parallel by default; this mutex serializes them.
    fn prefix_test_lock() -> &'static Mutex<()> {
        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
        LOCK.get_or_init(|| Mutex::new(()))
    }

    fn lock_prefix_state<'a>() -> std::sync::MutexGuard<'a, ()> {
        // Recover from poisoning so a panic in one test doesn't cascade.
        prefix_test_lock().lock().unwrap_or_else(|e| e.into_inner())
    }

    #[test]
    fn session_id_default_prefix_shape() {
        let _g = lock_prefix_state();
        let sid = generate_session_id();
        assert!(sid.len() >= SESSION_PREFIX_DEFAULT_LEN + 13 + SESSION_ID_RANDOM_SUFFIX_LEN);
    }

    #[test]
    fn session_id_with_custom_prefix() {
        let _g = lock_prefix_state();
        set_session_prefix("test_prefix_alpha");
        let sid = generate_session_id();
        assert!(sid.starts_with("test_prefix_alpha"));
    }

    #[test]
    fn session_id_empty_prefix_is_ignored() {
        let _g = lock_prefix_state();
        set_session_prefix("known_prefix_beta");
        set_session_prefix("");
        let sid = generate_session_id();
        assert!(sid.starts_with("known_prefix_beta"));
    }

    #[test]
    fn session_ids_are_unique() {
        let a = generate_session_id();
        let b = generate_session_id();
        assert_ne!(a, b);
    }

    #[test]
    fn uid_postfix_is_valid_hex() {
        let p = make_uid_postfix();
        assert!(!p.is_empty());
        assert!(p.chars().all(|c| c.is_ascii_hexdigit()));
    }

    #[test]
    fn uid_postfix_increases_over_time() {
        // Postfix is monotonic-ish: ms*1000 + rand[0..1000) means same ms
        // can produce out-of-order pairs by the rand component, but across
        // a sleep of >1ms the ms part dominates and ordering is monotonic.
        let a = u128::from_str_radix(&make_uid_postfix(), 16).unwrap();
        std::thread::sleep(std::time::Duration::from_millis(2));
        let b = u128::from_str_radix(&make_uid_postfix(), 16).unwrap();
        assert!(a < b);
    }

    #[test]
    fn make_user_id_format() {
        assert_eq!(make_user_id("a", "u", "c"), "connect.a.u.c");
    }

    #[test]
    fn parse_user_id_round_trip() {
        let s = make_user_id("app1", "user42", "client99");
        let parsed = parse_user_id(&s).unwrap();
        assert_eq!(parsed.id, s);
        assert_eq!(parsed.appid, "app1");
        assert_eq!(parsed.uid, "user42");
        assert_eq!(parsed.cuid, "client99");
    }

    #[test]
    fn parse_user_id_rejects_too_few_segments() {
        assert!(parse_user_id("connect.a.b").is_none());
        assert!(parse_user_id("nope").is_none());
        assert!(parse_user_id("").is_none());
    }

    #[test]
    fn parse_user_id_accepts_extra_trailing_segments() {
        // Node implementation only checks for >=4 segments; extras are ignored.
        let parsed = parse_user_id("connect.app1.uid1.cid1.extra").unwrap();
        assert_eq!(parsed.appid, "app1");
        assert_eq!(parsed.uid, "uid1");
        assert_eq!(parsed.cuid, "cid1");
    }

    #[test]
    fn make_db_key_format() {
        assert_eq!(make_db_key("user42"), "connect.user42");
    }
}