use std::{collections::HashMap, sync::OnceLock};
use rand::{Rng, seq::SliceRandom};
use serde::{Deserialize, Serialize};
use crate::{
assets,
config::{Config, Setting},
constants::{DEFAULT_LANGUAGE, WPS_TARGET},
error::AppError,
log_debug,
};
const MIN_POSSIBLE_WORD_COUNT: usize = 100;
const SYMBOLS: &[char] = &[
'@', '#', '$', '%', '&', '*', '(', ')', '+', '-', '/', '=', '?', '<', '>', '^', '_', '`', '{',
'|', '}', '~',
];
const PUNCTUATION: &[char] = &['.', ',', '!', '?', ';', ':'];
const NUMBERS: &[char] = &['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
const DEFAULT_LEXICON: &[&str] = &[
"the", "be", "to", "of", "and", "a", "in", "that", "have", "I", "it", "for", "not", "on",
"with", "he", "as", "you", "do", "at", "this", "but", "his", "by", "from", "they", "we", "say",
"her", "she", "or", "an", "will", "my", "one", "all", "would", "there", "their", "what", "so",
"up", "out", "if", "about", "who", "get", "which", "go", "me",
];
const SYMBOL_PROBABILITY: f64 = 0.20;
const PUNCTUATION_PROBABILITY: f64 = 0.30;
const NUMBER_PROBABILITY: f64 = 0.15;
#[derive(Debug, Serialize, Deserialize)]
struct Language {
name: String,
words: Vec<String>,
}
#[derive(Debug)]
pub struct Lexicon {
pub words: String,
builder: LexiconBuilder,
}
impl Lexicon {
pub fn new(config: &Config) -> Result<Self, AppError> {
let mut builder = LexiconBuilder::new();
let words = builder.generate_test(config)?;
Ok(Self { words, builder })
}
pub fn regenerate(&mut self, config: &Config) -> Result<(), AppError> {
self.words = self.builder.generate_test(config)?;
Ok(())
}
}
#[derive(Debug, Default)]
pub struct LexiconBuilder {
languages: HashMap<String, Vec<String>>,
shuffled_pools: HashMap<String, Vec<usize>>,
rng: rand::rngs::ThreadRng,
}
impl LexiconBuilder {
pub fn new() -> Self {
let mut builder = Self {
languages: HashMap::new(),
shuffled_pools: HashMap::new(),
rng: rand::rng(),
};
if builder.load_language(DEFAULT_LANGUAGE).is_err() {
Self::add_default_words(&mut builder);
}
builder
}
pub fn generate_test(&mut self, config: &Config) -> Result<String, AppError> {
if let Some(custom_words) = &config.cli.words {
return Ok(custom_words.clone());
}
let lang = config.current_language();
self.ensure_language_loaded(&lang)?;
let words = &self.languages[&lang];
let shuffled_idxs = &mut self.shuffled_pools.get_mut(&lang).unwrap();
shuffled_idxs.shuffle(&mut self.rng);
let word_count = if config.current_mode().is_time_mode() {
(config.current_mode().value() * WPS_TARGET).max(MIN_POSSIBLE_WORD_COUNT)
} else {
config.current_mode().value()
};
log_debug!("The word count: {word_count}");
let mut selected_words: Vec<&str> = (0..word_count)
.map(|i| words[shuffled_idxs[i % shuffled_idxs.len()]].as_str())
.collect();
selected_words.shuffle(&mut self.rng);
Self::prevent_consecutive_duplicates(&mut selected_words);
let extras: Vec<Option<char>> = (0..word_count)
.map(|_| {
let using_symbols = config.is_enabled(Setting::Symbols);
let using_numbers = config.is_enabled(Setting::Numbers);
let using_punctuation = config.is_enabled(Setting::Punctuation);
if using_symbols && self.rng.random_bool(SYMBOL_PROBABILITY) {
Some(SYMBOLS[self.rng.random_range(0..SYMBOLS.len())])
} else if using_punctuation && self.rng.random_bool(PUNCTUATION_PROBABILITY) {
Some(PUNCTUATION[self.rng.random_range(0..PUNCTUATION.len())])
} else if using_numbers && self.rng.random_bool(NUMBER_PROBABILITY) {
Some(NUMBERS[self.rng.random_range(0..NUMBERS.len())])
} else {
None
}
})
.collect();
let mut result = String::with_capacity(word_count * 10); for (i, word) in selected_words.iter().enumerate() {
result.push_str(word);
if let Some(extra) = extras[i] {
result.push(extra);
}
if i < selected_words.len() - 1 {
result.push(' ');
}
}
Ok(result)
}
fn add_default_words(builder: &mut Self) {
let words = DEFAULT_LEXICON
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>();
let mut idxs: Vec<usize> = (0..words.len()).collect();
idxs.shuffle(&mut builder.rng);
builder
.languages
.insert(DEFAULT_LANGUAGE.to_string(), words);
builder
.shuffled_pools
.insert(DEFAULT_LANGUAGE.to_string(), idxs);
}
fn ensure_language_loaded(&mut self, lang: &str) -> Result<(), AppError> {
if !self.languages.contains_key(lang) {
self.load_language(lang)?;
}
Ok(())
}
fn load_language(&mut self, lang: &str) -> Result<(), AppError> {
if !Self::has_language(lang) {
return Err(AppError::InvalidLanguage(lang.to_string()));
}
let content = assets::get_language(lang)
.ok_or_else(|| AppError::Other(format!("Language not found: {}", lang)))?;
let language: Language = serde_json::from_str(&content)?;
let mut idxs: Vec<usize> = (0..language.words.len()).collect();
idxs.shuffle(&mut self.rng);
self.languages.insert(language.name.clone(), language.words);
self.shuffled_pools.insert(language.name, idxs);
Ok(())
}
fn prevent_consecutive_duplicates(words: &mut [&str]) {
for i in 1..words.len() {
if words[i] == words[i - 1] {
let start = i + 1;
let end = (start + 10).min(words.len()); for j in start..end {
if words[j] != words[i] {
words.swap(i, j);
break;
}
}
}
}
}
pub fn available_languages() -> &'static [String] {
static LANGUAGES: OnceLock<Vec<String>> = OnceLock::new();
LANGUAGES.get_or_init(assets::list_languages)
}
pub fn has_language(language: &str) -> bool {
Self::available_languages()
.iter()
.any(|lang| lang == language)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
config::{Config, Mode},
constants::MAX_CUSTOM_TIME,
};
use std::collections::HashSet;
fn create_builder() -> LexiconBuilder {
LexiconBuilder::new()
}
#[test]
fn test_default_builder_state() {
let builder = create_builder();
assert!(!builder.languages.is_empty());
assert!(builder.languages.contains_key(DEFAULT_LANGUAGE));
}
#[test]
fn test_language_loading() {
let mut builder = create_builder();
assert!(builder.languages.contains_key(DEFAULT_LANGUAGE));
builder
.load_language("spanish")
.expect("Failed to load spanish");
assert!(builder.languages.contains_key("spanish"));
}
#[test]
fn test_invalid_language() {
let mut builder = create_builder();
let result = builder.load_language("invalid");
assert!(result.is_err());
}
#[test]
fn test_no_back_to_back_duplicates() {
let mut builder = create_builder();
let mut config = Config::default();
config
.change_mode(crate::config::Mode::with_words(50))
.unwrap();
let test = builder.generate_test(&config).unwrap();
let words: Vec<&str> = test.split_whitespace().collect();
for i in 1..words.len() {
assert_ne!(
words[i],
words[i - 1],
"Found consecutive duplicate words: {} at index {}",
words[i],
i
);
}
}
#[test]
fn test_word_count() {
let mut builder = create_builder();
let mut config = Config::default();
let count = 10;
config
.change_mode(crate::config::Mode::with_words(count))
.unwrap();
let test = builder.generate_test(&config).unwrap();
assert_eq!(test.split_whitespace().count(), count);
}
#[test]
fn test_time_mode() {
let mut builder = create_builder();
let mut config = Config::default();
let seconds_arr: [usize; 5] = [1, 10, 60, 120, MAX_CUSTOM_TIME];
for seconds in seconds_arr {
config.change_mode(Mode::with_time(seconds)).unwrap();
let test = builder.generate_test(&config).unwrap();
assert!(test.split_whitespace().count() >= WPS_TARGET * seconds);
}
}
#[test]
fn test_ensure_enough_words_low_time_modes() {
let mut builder = create_builder();
let mut config = Config::default();
let seconds = 2;
config.change_mode(Mode::with_time(seconds)).unwrap();
let test = builder.generate_test(&config).unwrap();
assert!(test.split_whitespace().count() >= MIN_POSSIBLE_WORD_COUNT);
}
#[test]
fn test_restart_generates_new_words_word_mode() {
let mut builder = create_builder();
let mut config = Config::default();
let word_count = 20;
config.change_mode(Mode::with_words(word_count)).unwrap();
let first_test = builder.generate_test(&config).unwrap();
let first_words: Vec<&str> = first_test.split_whitespace().collect();
let second_test = builder.generate_test(&config).unwrap();
let second_words: Vec<&str> = second_test.split_whitespace().collect();
assert_eq!(first_words.len(), word_count);
assert_eq!(second_words.len(), word_count);
let first_set: HashSet<&str> = first_words.iter().cloned().collect();
let second_set: HashSet<&str> = second_words.iter().cloned().collect();
assert_ne!(
first_set, second_set,
"Restart should generate a new set of words, not just shuffle the existing ones. \
First set: {:?}, Second set: {:?}",
first_set, second_set
);
assert_ne!(first_test, second_test);
}
}