bento-kit 0.1.1

A bento box of common Rust utilities: id generation, timing, masking
Documentation
//! Sensitive-data masking helpers.
//!
//! Each function takes a `&str` and returns a `String`. Inputs that don't
//! match the expected shape are returned with a best-effort mask rather
//! than panicking — these functions are intended for log output, where
//! crashing is worse than over-masking.

const MASK_CHAR: char = '*';

fn star_string(n: usize) -> String {
    MASK_CHAR.to_string().repeat(n)
}

/// Mask a Chinese mainland mobile number: `13812345678` → `138****5678`.
///
/// If the input is not exactly 11 digits, every char except the first and
/// last is masked.
pub fn mask_phone(phone: &str) -> String {
    if phone.len() == 11 && phone.chars().all(|c| c.is_ascii_digit()) {
        format!("{}****{}", &phone[..3], &phone[7..])
    } else {
        mask_middle(phone, 1, 1)
    }
}

/// Mask an email address: `alice@example.com` → `a***@example.com`.
///
/// If `@` is missing the whole input is masked except the first character.
pub fn mask_email(email: &str) -> String {
    let Some(at) = email.find('@') else {
        return mask_middle(email, 1, 0);
    };
    let (local, domain) = email.split_at(at);
    if local.is_empty() {
        return email.to_string();
    }
    let first = local.chars().next().unwrap();
    format!("{first}***{domain}")
}

/// Mask a Chinese ID card number (15 or 18 digits/X):
/// `110101199001011234` → `110101********1234`.
pub fn mask_id_card(id: &str) -> String {
    let len = id.chars().count();
    if !(len == 15 || len == 18) {
        return mask_middle(id, 1, 1);
    }
    mask_middle(id, 6, 4)
}

/// Mask a bank card number, keeping the first 4 and last 4 digits.
///
/// `6225881234567890` → `6225********7890`.
pub fn mask_bank_card(card: &str) -> String {
    if card.chars().count() < 9 {
        return mask_middle(card, 1, 1);
    }
    mask_middle(card, 4, 4)
}

/// Mask an API token / secret. Shows the first 4 and last 4 characters,
/// or fully stars out short inputs.
///
/// `sk-1234567890abcdef` → `sk-1**********cdef`.
pub fn mask_token(token: &str) -> String {
    if token.chars().count() < 9 {
        return star_string(token.chars().count());
    }
    mask_middle(token, 4, 4)
}

/// Mask a Chinese name.
///
/// - 1 char  → unchanged (nothing to mask).
/// - 2 chars → keep the first, star the second: `张三` → `张*`.
/// - 3+      → keep first and last, star the middle: `欧阳修远` → `欧**远`.
pub fn mask_name(name: &str) -> String {
    let chars: Vec<char> = name.chars().collect();
    match chars.len() {
        0 | 1 => name.to_string(),
        2 => format!("{}{MASK_CHAR}", chars[0]),
        n => {
            let middle = star_string(n - 2);
            format!("{}{middle}{}", chars[0], chars[n - 1])
        }
    }
}

/// Generic credential masking — port of `du-node-utils.maskSecret`.
///
/// Behavior:
/// - `None`        → `"<none>"`
/// - `Some("")`    → `"<empty>"`
/// - Length ≤ `keep_chars * 2` → returned as-is (too short to safely mask)
/// - Otherwise     → `first(keep_chars) + "***" + last(keep_chars)` —
///   note the middle is **always exactly three stars**, regardless of
///   the masked length, matching the Node implementation.
///
/// `keep_chars = 3` is a sensible default for API tokens / secrets.
///
/// ```
/// use bento_kit::mask::mask_secret;
/// assert_eq!(mask_secret(Some("vault:AES256:abcXYZ"), 3), "vau***XYZ");
/// assert_eq!(mask_secret(Some("ab"), 3), "ab"); // too short to mask
/// assert_eq!(mask_secret(None, 3), "<none>");
/// assert_eq!(mask_secret(Some(""), 3), "<empty>");
/// ```
pub fn mask_secret(value: Option<&str>, keep_chars: usize) -> String {
    let s = match value {
        None => return "<none>".to_string(),
        Some("") => return "<empty>".to_string(),
        Some(v) => v,
    };
    let chars: Vec<char> = s.chars().collect();
    let len = chars.len();
    if len <= keep_chars * 2 {
        return s.to_string();
    }
    let head: String = chars[..keep_chars].iter().collect();
    let tail: String = chars[len - keep_chars..].iter().collect();
    format!("{head}***{tail}")
}

/// Generic helper: keep `prefix` chars at the start and `suffix` chars at
/// the end, replacing the middle with stars (one star per masked char).
///
/// If the string is too short to satisfy `prefix + suffix`, the entire
/// string is returned starred.
pub fn mask_middle(s: &str, prefix: usize, suffix: usize) -> String {
    let chars: Vec<char> = s.chars().collect();
    let total = chars.len();
    if total <= prefix + suffix {
        return star_string(total);
    }
    let head: String = chars[..prefix].iter().collect();
    let tail: String = chars[total - suffix..].iter().collect();
    let middle = star_string(total - prefix - suffix);
    format!("{head}{middle}{tail}")
}

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

    #[test]
    fn phone_well_formed() {
        assert_eq!(mask_phone("13812345678"), "138****5678");
    }

    #[test]
    fn phone_malformed_falls_back_to_generic() {
        // Not 11 digits — generic masking.
        assert_eq!(mask_phone("12345"), "1***5");
        assert_eq!(mask_phone("138-1234-5678"), "1***********8");
    }

    #[test]
    fn phone_empty_returns_empty() {
        assert_eq!(mask_phone(""), "");
    }

    #[test]
    fn email_typical() {
        assert_eq!(mask_email("alice@example.com"), "a***@example.com");
    }

    #[test]
    fn email_single_char_local() {
        assert_eq!(mask_email("a@b.com"), "a***@b.com");
    }

    #[test]
    fn email_no_at_sign_falls_back() {
        // No @, treat as opaque string and mask everything but first.
        assert_eq!(mask_email("not-an-email"), "n***********");
    }

    #[test]
    fn email_starts_with_at_returned_as_is() {
        assert_eq!(mask_email("@example.com"), "@example.com");
    }

    #[test]
    fn id_card_18_digits() {
        assert_eq!(mask_id_card("110101199001011234"), "110101********1234");
    }

    #[test]
    fn id_card_15_digits() {
        // 6 prefix + 5 stars + 4 suffix = 15 chars.
        assert_eq!(mask_id_card("123456789012345"), "123456*****2345");
    }

    #[test]
    fn id_card_unexpected_length_falls_back() {
        // Generic fallback keeps first + last, stars the middle.
        assert_eq!(mask_id_card("123"), "1*3");
        assert_eq!(mask_id_card("12345"), "1***5");
    }

    #[test]
    fn bank_card_typical() {
        assert_eq!(mask_bank_card("6225881234567890"), "6225********7890");
    }

    #[test]
    fn bank_card_short() {
        // < 9 chars hits the fallback path.
        assert_eq!(mask_bank_card("12345678"), "1******8");
    }

    #[test]
    fn token_typical() {
        assert_eq!(mask_token("sk-1234567890abcdef"), "sk-1***********cdef");
    }

    #[test]
    fn token_short_is_fully_starred() {
        assert_eq!(mask_token("abc12345"), "********");
        assert_eq!(mask_token(""), "");
    }

    #[test]
    fn name_one_char_unchanged() {
        assert_eq!(mask_name(""), "");
    }

    #[test]
    fn name_two_chars() {
        assert_eq!(mask_name("张三"), "张*");
    }

    #[test]
    fn name_three_chars() {
        assert_eq!(mask_name("张三丰"), "张*丰");
    }

    #[test]
    fn name_four_chars() {
        assert_eq!(mask_name("欧阳修远"), "欧**远");
    }

    #[test]
    fn mask_middle_too_short_stars_everything() {
        assert_eq!(mask_middle("abc", 2, 2), "***");
        assert_eq!(mask_middle("ab", 1, 1), "**");
    }

    #[test]
    fn mask_middle_unicode_is_char_based_not_byte_based() {
        // Each Chinese char is 3 bytes in UTF-8 — confirm we're counting chars.
        assert_eq!(mask_middle("一二三四五", 1, 1), "一***五");
    }

    #[test]
    fn mask_secret_typical() {
        assert_eq!(mask_secret(Some("vault:AES256:abcXYZ"), 3), "vau***XYZ");
    }

    #[test]
    fn mask_secret_uses_three_stars_regardless_of_length() {
        // A long input still gets exactly "***" in the middle.
        let long_secret = "a".repeat(50) + "END12345";
        let masked = mask_secret(Some(&long_secret), 4);
        assert_eq!(masked, "aaaa***2345");
    }

    #[test]
    fn mask_secret_short_returned_as_is() {
        // len <= keep*2 → no masking
        assert_eq!(mask_secret(Some("abcdef"), 3), "abcdef");
        assert_eq!(mask_secret(Some("ab"), 3), "ab");
    }

    #[test]
    fn mask_secret_sentinels_for_none_and_empty() {
        assert_eq!(mask_secret(None, 3), "<none>");
        assert_eq!(mask_secret(Some(""), 3), "<empty>");
    }

    #[test]
    fn mask_secret_keep_zero_stars_everything() {
        assert_eq!(mask_secret(Some("hello"), 0), "***");
    }

    #[test]
    fn mask_secret_unicode_safe() {
        // 8 Chinese chars, keep 2 → "一二***七八"
        assert_eq!(mask_secret(Some("一二三四五六七八"), 2), "一二***七八");
    }
}