apollo-redaction 0.2.0

Redaction utility for sensitive data in error messages, debug logs, etc.
Documentation
use crate::RedactionStrategy;
use std::fmt;

/// Redact credit card numbers while preserving format.
///
/// Replaces all digits except the last 4 with asterisks, preserving
/// any separators (spaces, dashes) in their original positions.
///
/// Card numbers with fewer than 13 digits are considered invalid.
///
/// ```rust
/// use apollo_redaction::Redacted;
/// use apollo_redaction::strategy::CardRedactor;
///
/// fn card(input: &str) -> String {
///     Redacted::<_, CardRedactor>::new(input).to_string()
/// }
///
/// assert_eq!(card("4242424242424242"), "************4242");
/// assert_eq!(card("4242-4242-4242-4242"), "****-****-****-4242");
/// assert_eq!(card("4242 4242 4242 4242"), "**** **** **** 4242");
/// assert_eq!(card("1234"), "[REDACTED: invalid card number]");
/// ```
#[derive(Default, Clone)]
pub struct CardRedactor;

/// Minimum valid card number length (some Maestro cards)
const MIN_CARD_DIGITS: usize = 13;

impl<T: AsRef<str>> RedactionStrategy<T> for CardRedactor {
    fn display(&self, value: &T, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let s = value.as_ref();

        // Count total digits to determine how many to redact
        let total_digits = s.chars().filter(|c| c.is_ascii_digit()).count();

        // Invalid card numbers get a fixed redaction message
        if total_digits < MIN_CARD_DIGITS {
            return write!(f, "[REDACTED: invalid card number]");
        }

        let redact_count = total_digits - 4;

        // Single pass: redact first N digits, preserve everything else
        let mut digits_seen = 0;
        for c in s.chars() {
            if c.is_ascii_digit() {
                if digits_seen < redact_count {
                    write!(f, "*")?;
                } else {
                    write!(f, "{c}")?;
                }
                digits_seen += 1;
            } else {
                write!(f, "{c}")?;
            }
        }

        Ok(())
    }
}

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

    fn card(input: &str) -> String {
        Redacted::<_, CardRedactor>::new(input).to_string()
    }

    #[test]
    fn test_plain_card() {
        assert_eq!(card("4242424242424242"), "************4242");
    }

    #[test]
    fn test_amex() {
        assert_eq!(card("378282246310005"), "***********0005");
    }

    #[test]
    fn test_19_digit_card() {
        assert_eq!(card("6011000000000000000"), "***************0000");
    }

    #[test]
    fn test_invalid_card() {
        let expected = "[REDACTED: invalid card number]";
        assert_eq!(card("123"), expected);
        assert_eq!(card("1234"), expected);
        assert_eq!(card("123456789012"), expected); // 12 digits
        assert_eq!(card("1234-5678-9012"), expected); // 12 digits with formatting
        assert_eq!(card("no digits here"), expected);
    }

    #[test]
    fn test_13_digit_card() {
        assert_eq!(card("4000000000000"), "*********0000");
    }

    #[test]
    fn test_with_dashes() {
        assert_eq!(card("4242-4242-4242-4242"), "****-****-****-4242");
    }

    #[test]
    fn test_with_spaces() {
        assert_eq!(card("4242 4242 4242 4242"), "**** **** **** 4242");
    }

    #[test]
    fn test_leading_trailing_spaces() {
        assert_eq!(card("  4242424242424242  "), "  ************4242  ");
    }

    #[test]
    fn test_mixed_formatting() {
        assert_eq!(card(" 4242-4242 4242-4242 "), " ****-**** ****-4242 ");
    }
}