steam-friend-code 0.1.2

Encode and decode Steam CS:GO/CS2 friend codes and short Steam quick-invite codes.
Documentation
//! Short Steam friend code (hex-mapping for `s.team/p/` links).
//!
//! These short codes are used in Steam quick invite links to encode
//! a user's account ID using a consonant substitution cipher.

/// Character set for short friend code encoding.
/// Maps hex digits 0–15 to consonant-like characters.
pub const SHORT_STEAM_FRIEND_CODE_CHARS: [char; 16] = ['b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'm', 'n', 'p', 'q', 'r', 't', 'v', 'w'];

/// Create a short friend code from an account ID.
///
/// Short friend codes are used in quick invite links (`https://s.team/p/{code}/{token}`).
/// They encode the account ID's hex digits using a consonant substitution
/// cipher.
///
/// # Example
/// ```
/// let code = steam_friend_code::create_short_steam_friend_code(123456789);
/// assert!(code.contains('-'));
/// ```
pub fn create_short_steam_friend_code(account_id: u32) -> String {
    let hex = format!("{:x}", account_id);

    let mut friend_code = String::with_capacity(hex.len() + 1);

    for c in hex.chars() {
        let digit = c.to_digit(16).unwrap_or(0) as usize;
        friend_code.push(SHORT_STEAM_FRIEND_CODE_CHARS[digit]);
    }

    // Insert dash in the middle
    let dash_pos = friend_code.len() / 2;
    if dash_pos > 0 && friend_code.len() > 1 {
        friend_code.insert(dash_pos, '-');
    }

    friend_code
}

/// Parse a short friend code back into an account ID.
///
/// Returns `None` if the code contains invalid characters.
pub fn parse_short_steam_friend_code(friend_code: &str) -> Option<u32> {
    let code = friend_code.replace('-', "");
    let mut hex = String::with_capacity(code.len());

    for c in code.chars() {
        let pos = SHORT_STEAM_FRIEND_CODE_CHARS.iter().position(|&x| x == c)?;
        hex.push(char::from_digit(pos as u32, 16).unwrap());
    }

    u32::from_str_radix(&hex, 16).ok()
}

/// Parse a quick invite link URL into (friend_code, token).
///
/// Supported formats:
/// - `https://s.team/p/{friend_code}/{token}`
/// - `https://steamcommunity.com/user/{friend_code}/{token}`
/// - `{friend_code}/{token}` (just the path)
///
/// Returns `None` if the link format is invalid.
pub fn parse_quick_invite_link(link: &str) -> Option<(String, String)> {
    let path = link.trim().trim_start_matches("https://").trim_start_matches("http://").trim_start_matches("s.team/p/").trim_start_matches("steamcommunity.com/user/");

    let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();

    if parts.len() < 2 {
        return None;
    }

    Some((parts[0].to_string(), parts[1].to_string()))
}

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

    #[test]
    fn test_create_short_steam_friend_code() {
        let code = create_short_steam_friend_code(123456789);
        for c in code.chars() {
            assert!(SHORT_STEAM_FRIEND_CODE_CHARS.contains(&c) || c == '-');
        }
        assert_eq!(code.matches('-').count(), 1);
    }

    #[test]
    fn test_parse_short_steam_friend_code_roundtrip() {
        let ids = [123456789u32, 1, 999999, 2147483647];
        for id in ids {
            let code = create_short_steam_friend_code(id);
            let parsed = parse_short_steam_friend_code(&code);
            assert_eq!(parsed, Some(id), "Roundtrip failed for id {} code {}", id, code);
        }
    }

    #[test]
    fn test_parse_quick_invite_link() {
        let (code, token) = parse_quick_invite_link("https://s.team/p/bcdf-ghjk/ABCD1234").unwrap();
        assert_eq!(code, "bcdf-ghjk");
        assert_eq!(token, "ABCD1234");

        let (code, token) = parse_quick_invite_link("bcdf-ghjk/TOKEN").unwrap();
        assert_eq!(code, "bcdf-ghjk");
        assert_eq!(token, "TOKEN");
    }

    #[test]
    fn test_parse_quick_invite_link_invalid() {
        assert!(parse_quick_invite_link("https://s.team/p/bcdf-ghjk").is_none());
        assert!(parse_quick_invite_link("").is_none());
    }
}