use rand::{Rng, RngExt};
const GARBLE_CHARS: &[u8] =
b"!@#$%^&*()_+-=[]{}|;:,.<>?`~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
pub fn garble_text<R: Rng>(length: usize, rng: &mut R) -> String {
(0..length)
.map(|_| GARBLE_CHARS[rng.random_range(0..GARBLE_CHARS.len())] as char)
.collect()
}
pub fn garble_frame<R: Rng>(from_len: usize, to_len: usize, progress: f64, rng: &mut R) -> String {
let interpolated = from_len as f64 + (to_len as f64 - from_len as f64) * progress.clamp(0.0, 1.0);
let length = interpolated.round().max(1.0) as usize;
garble_text(length, rng)
}
pub fn decode_frame<R: Rng>(target: &str, revealed_count: usize, rng: &mut R) -> String {
let chars: Vec<char> = target.chars().collect();
let revealed = revealed_count.min(chars.len());
let prefix: String = chars[..revealed].iter().collect();
let remaining = chars.len().saturating_sub(revealed);
if remaining == 0 {
return prefix;
}
let suffix = garble_text(remaining, rng);
format!("{prefix}{suffix}")
}
pub fn encode_frame<R: Rng>(target: &str, garbled_count: usize, rng: &mut R) -> String {
let chars: Vec<char> = target.chars().collect();
let garbled = garbled_count.min(chars.len());
let suffix: String = chars[garbled..].iter().collect();
if garbled == 0 {
return suffix;
}
let prefix = garble_text(garbled, rng);
format!("{prefix}{suffix}")
}
pub fn is_fully_revealed(target: &str, revealed_count: usize) -> bool {
revealed_count >= target.chars().count()
}
pub fn is_fully_garbled(target: &str, garbled_count: usize) -> bool {
garbled_count >= target.chars().count()
}
pub fn decode_text<R: Rng>(target: &str, tick: usize, garble_ticks: usize, rng: &mut R) -> String {
if tick < garble_ticks {
garble_frame(1, target.len(), tick as f64 / garble_ticks as f64, rng)
} else {
decode_frame(target, tick - garble_ticks, rng)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng;
use rand::rngs::SmallRng;
fn test_rng() -> SmallRng {
SmallRng::seed_from_u64(42)
}
#[test]
fn garble_text_produces_correct_length() {
let mut rng = test_rng();
assert_eq!(garble_text(10, &mut rng).len(), 10);
assert_eq!(garble_text(0, &mut rng).len(), 0);
assert_eq!(garble_text(100, &mut rng).len(), 100);
}
#[test]
fn garble_text_uses_only_valid_chars() {
let mut rng = test_rng();
let text = garble_text(200, &mut rng);
for ch in text.chars() {
assert!(GARBLE_CHARS.contains(&(ch as u8)), "unexpected char: {ch}");
}
}
#[test]
fn garble_frame_interpolates_length() {
let mut rng = test_rng();
let start = garble_frame(5, 20, 0.0, &mut rng);
let middle = garble_frame(5, 20, 0.5, &mut rng);
let end = garble_frame(5, 20, 1.0, &mut rng);
assert_eq!(start.len(), 5);
assert_eq!(middle.len(), 13); assert_eq!(end.len(), 20);
}
#[test]
fn garble_frame_clamps_progress() {
let mut rng = test_rng();
let below = garble_frame(5, 20, -1.0, &mut rng);
let above = garble_frame(5, 20, 2.0, &mut rng);
assert_eq!(below.len(), 5);
assert_eq!(above.len(), 20);
}
#[test]
fn decode_frame_reveals_prefix_progressively() {
let mut rng = test_rng();
let target = "hello world";
let frame0 = decode_frame(target, 0, &mut rng);
let frame5 = decode_frame(target, 5, &mut rng);
let frame_full = decode_frame(target, 11, &mut rng);
assert_eq!(frame0.chars().count(), target.chars().count());
assert!(!frame0.starts_with('h'));
assert!(frame5.starts_with("hello"));
assert_eq!(frame5.chars().count(), target.chars().count());
assert_eq!(frame_full, "hello world");
}
#[test]
fn decode_frame_handles_beyond_target_length() {
let mut rng = test_rng();
let target = "hi";
let frame = decode_frame(target, 100, &mut rng);
assert_eq!(frame, "hi");
}
#[test]
fn is_fully_revealed_checks_char_count() {
assert!(!is_fully_revealed("hello", 4));
assert!(is_fully_revealed("hello", 5));
assert!(is_fully_revealed("hello", 6));
}
#[test]
fn encode_frame_garbles_prefix_progressively() {
let mut rng = test_rng();
let target = "hello world";
let frame0 = encode_frame(target, 0, &mut rng);
let frame5 = encode_frame(target, 5, &mut rng);
let frame_full = encode_frame(target, 11, &mut rng);
assert_eq!(frame0, "hello world");
assert!(frame5.ends_with(" world"));
assert_eq!(frame5.chars().count(), target.chars().count());
assert_eq!(frame_full.chars().count(), target.chars().count());
assert!(!frame_full.contains("hello"));
}
#[test]
fn encode_frame_handles_beyond_target_length() {
let mut rng = test_rng();
let target = "hi";
let frame = encode_frame(target, 100, &mut rng);
assert_eq!(frame.chars().count(), 2);
}
#[test]
fn is_fully_garbled_checks_char_count() {
assert!(!is_fully_garbled("hello", 4));
assert!(is_fully_garbled("hello", 5));
assert!(is_fully_garbled("hello", 6));
}
#[test]
fn decode_preserves_multibyte_target() {
let mut rng = test_rng();
let target = "café";
let frame = decode_frame(target, 3, &mut rng);
assert!(frame.starts_with("caf"));
assert_eq!(frame.chars().count(), 4);
}
#[test]
fn decode_multibyte_char_count_stable_but_byte_len_varies() {
let mut rng = test_rng();
let target = "café résumé";
let target_chars = target.chars().count();
for revealed in 0..=target_chars {
let frame = decode_frame(target, revealed, &mut rng);
assert_eq!(
frame.chars().count(),
target_chars,
"char count must equal target at revealed={revealed}"
);
}
let partial = decode_frame(target, 2, &mut test_rng());
assert_ne!(
partial.len(),
target.len(),
"byte length of partially-decoded multi-byte string should differ from target — \
centering must use chars().count(), not len()"
);
}
#[test]
fn decode_text_garbles_then_reveals() {
let mut rng = test_rng();
let target = "hello";
let garble_ticks = 5;
let early = decode_text(target, 2, garble_ticks, &mut rng);
assert!(!early.is_empty());
assert_ne!(early, "hello");
let revealed = decode_text(target, garble_ticks + 10, garble_ticks, &mut rng);
assert_eq!(revealed, "hello");
}
}