mx-core 0.1.0

Core utilities for MultiversX Rust services.
Documentation
//! Encoding and decoding utilities for base64 and hex.
//!
//! Provides helper functions commonly used throughout the codebase.

use base64::Engine;
use prost::bytes::Bytes;

use crate::error::CoreError;

/// Decodes a base64-encoded string, trying standard encoding first.
///
/// # Arguments
/// * `input` - The base64-encoded string
///
/// # Returns
/// The decoded bytes, or an error message if decoding fails.
pub fn decode_base64(input: &str) -> Result<Vec<u8>, CoreError> {
    base64::engine::general_purpose::STANDARD
        .decode(input.trim())
        .map_err(|e| CoreError::InvalidBase64(e.to_string()))
}

/// Decodes a base64-encoded string to Bytes.
///
/// # Arguments
/// * `input` - The base64-encoded string
///
/// # Returns
/// The decoded Bytes, or an error message if decoding fails.
pub fn decode_base64_bytes(input: &str) -> Result<Bytes, CoreError> {
    decode_base64(input).map(Bytes::from)
}

/// Decodes an optional base64-encoded string.
///
/// # Arguments
/// * `input` - Optional base64-encoded string
///
/// # Returns
/// None if input is None or empty, otherwise the decoded Bytes or an error.
pub fn decode_optional_base64(input: Option<&str>) -> Result<Option<Bytes>, CoreError> {
    match input {
        Some(s) if !s.trim().is_empty() => decode_base64_bytes(s).map(Some),
        _ => Ok(None),
    }
}

/// Decodes a hex-encoded string.
///
/// # Arguments
/// * `input` - The hex-encoded string
///
/// # Returns
/// The decoded bytes as Bytes, or an error message if decoding fails.
pub fn decode_hex(input: &str) -> Result<Bytes, CoreError> {
    hex::decode(input.trim())
        .map(Bytes::from)
        .map_err(|e| CoreError::InvalidHex(e.to_string()))
}

/// Decodes an optional hex-encoded string.
///
/// # Arguments
/// * `input` - Optional hex-encoded string
///
/// # Returns
/// None if input is None or empty, otherwise the decoded Bytes or an error.
pub fn decode_optional_hex(input: Option<&str>) -> Result<Option<Bytes>, CoreError> {
    match input {
        Some(s) if !s.trim().is_empty() => decode_hex(s).map(Some),
        _ => Ok(None),
    }
}

/// Decodes data that may be either base64 or hex encoded.
/// Tries base64 first, then falls back to hex.
///
/// This matches the Go implementation's permissive decoding behavior.
///
/// # Warning: Ambiguous inputs
///
/// Strings that are valid in **both** base64 and hex will always be decoded as
/// base64. For example, `"AABB"` is valid hex (`[0xAA, 0xBB]`) *and* valid
/// base64 (`[0x00, 0x00, 0x1B]`). Because base64 is tried first, the base64
/// interpretation wins. This is **intentional** — the Go SDK uses the same
/// heuristic. Callers who know the encoding should use [`decode_base64`] or
/// [`decode_hex`] directly.
///
/// # Arguments
/// * `input` - The encoded string (base64 or hex)
///
/// # Returns
/// The decoded bytes as Bytes.
pub fn decode_base64_or_hex(input: &str) -> Result<Bytes, CoreError> {
    // Try base64 first; if it fails, fall back to hex decode
    match base64::engine::general_purpose::STANDARD.decode(input.trim()) {
        Ok(bytes) => Ok(Bytes::from(bytes)),
        Err(_) => decode_hex(input),
    }
}

/// Filters and trims a username field.
///
/// Returns None if the username is empty or only whitespace after trimming.
///
/// # Arguments
/// * `username` - Optional username string
///
/// # Returns
/// The trimmed username if non-empty, otherwise None.
pub fn filter_username(username: Option<&str>) -> Option<&str> {
    username.map(str::trim).filter(|s| !s.is_empty())
}

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

    #[test]
    fn test_decode_base64() {
        let encoded = "SGVsbG8gV29ybGQ="; // "Hello World"
        let decoded = decode_base64(encoded).unwrap();
        assert_eq!(decoded, b"Hello World");
    }

    #[test]
    fn test_decode_base64_invalid() {
        let result = decode_base64("not-valid-base64!!!");
        assert!(result.is_err());
    }

    #[test]
    fn test_decode_hex() {
        let encoded = "48656c6c6f"; // "Hello"
        let decoded = decode_hex(encoded).unwrap();
        assert_eq!(decoded.as_ref(), b"Hello");
    }

    #[test]
    fn test_decode_hex_invalid() {
        let result = decode_hex("not-valid-hex");
        assert!(result.is_err());
    }

    #[test]
    fn test_decode_base64_or_hex_base64() {
        let encoded = "SGVsbG8="; // "Hello" in base64
        let decoded = decode_base64_or_hex(encoded).unwrap();
        assert_eq!(decoded.as_ref(), b"Hello");
    }

    #[test]
    fn test_decode_base64_or_hex_hex() {
        let encoded = "48656c6c6f"; // "Hello" in hex
        let decoded = decode_base64_or_hex(encoded).unwrap();
        assert_eq!(decoded.as_ref(), b"Hello");
    }

    #[test]
    fn test_decode_optional_base64_none() {
        let result = decode_optional_base64(None).unwrap();
        assert!(result.is_none());
    }

    #[test]
    fn test_decode_optional_base64_empty() {
        let result = decode_optional_base64(Some("")).unwrap();
        assert!(result.is_none());
    }

    #[test]
    fn test_decode_optional_hex_none() {
        let result = decode_optional_hex(None).unwrap();
        assert!(result.is_none());
    }

    #[test]
    fn test_decode_base64_or_hex_ambiguity() {
        // "AABB" is valid both as hex ([0xAA, 0xBB]) and base64 ([0x00, 0x00, 0x1B]).
        // Because base64 is tried first, the base64 interpretation wins.
        // This is intentional behavior matching the Go SDK.
        let decoded = decode_base64_or_hex("AABB").unwrap();
        // base64("AABB") = [0x00, 0x00, 0x41], NOT hex [0xAA, 0xBB]
        assert_eq!(decoded.as_ref(), &[0x00, 0x00, 0x41]);
        assert_ne!(decoded.as_ref(), &[0xAA, 0xBB]);
    }

    #[test]
    fn test_filter_username_valid() {
        let result = filter_username(Some("user123"));
        assert_eq!(result, Some("user123"));
    }

    #[test]
    fn test_filter_username_empty() {
        let result = filter_username(Some(""));
        assert_eq!(result, None);
    }

    #[test]
    fn test_filter_username_whitespace() {
        let result = filter_username(Some("  "));
        assert_eq!(result, None);
    }

    #[test]
    fn test_filter_username_none() {
        let result = filter_username(None);
        assert_eq!(result, None);
    }
}