use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::io::Write;
use std::time::{Duration, Instant, SystemTime};
use fs2::FileExt;
use crate::language::Language;
#[derive(Debug, Clone)]
struct LeaderboardCache {
entries: Vec<LeaderboardEntry>,
last_modified: SystemTime,
cached_at: Instant,
}
pub struct LeaderboardData {
pub open: bool,
pub entries: Vec<LeaderboardEntry>,
pub selected: usize,
}
impl LeaderboardCache {
fn is_valid(&self, file_path: &PathBuf) -> bool {
if self.cached_at.elapsed() > Duration::from_secs(30) {
return false;
}
if let Ok(metadata) = fs::metadata(file_path) {
if let Ok(modified) = metadata.modified() {
return modified <= self.last_modified;
}
}
false
}
}
use std::sync::{Mutex, OnceLock};
static LEADERBOARD_CACHE: OnceLock<Mutex<Option<LeaderboardCache>>> = OnceLock::new();
fn get_cache() -> &'static Mutex<Option<LeaderboardCache>> {
LEADERBOARD_CACHE.get_or_init(|| Mutex::new(None))
}
fn invalidate_cache() {
if let Ok(mut cache) = get_cache().lock() {
*cache = None;
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct LeaderboardEntry {
pub wpm: f64,
pub accuracy: f64,
pub test_type: TestType,
pub test_mode: String,
pub word_count: usize,
pub test_duration: f64,
pub timestamp: String,
pub language: Language,
}
impl LeaderboardEntry {
pub fn validate(&self) -> Result<(), ValidationError> {
if self.wpm < 0.0 || self.wpm > 300.0 {
return Err(ValidationError::InvalidWpm(self.wpm));
}
if self.accuracy < 0.0 || self.accuracy > 100.0 {
return Err(ValidationError::InvalidAccuracy(self.accuracy));
}
if self.test_duration < 0.0 || self.test_duration > 86400.0 {
return Err(ValidationError::InvalidTestDuration(self.test_duration));
}
if self.word_count > 10000 {
return Err(ValidationError::InvalidWordCount(self.word_count));
}
if self.test_mode.len() > 20 {
return Err(ValidationError::FieldTooLong(
format!("test_mode too long: {}", self.test_mode.len())
));
}
if let Err(_) = chrono::DateTime::parse_from_rfc3339(&self.timestamp) {
return Err(ValidationError::InvalidTimestamp(self.timestamp.clone()));
}
Ok(())
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub enum TestType {
Time(u32),
Word(usize),
Quote,
Practice(usize),
Wiki,
}
#[derive(Debug)]
pub enum ValidationError {
InvalidWpm(f64),
InvalidAccuracy(f64),
InvalidTimestamp(String),
FieldTooLong(String),
InvalidTestDuration(f64),
InvalidWordCount(usize),
}
#[derive(Debug)]
pub enum LeaderboardError {
IoError(std::io::Error),
SerializationError(serde_json::Error),
ValidationError(ValidationError),
LockTimeout,
LockError(String),
}
pub struct FileLockGuard {
_file: fs::File,
}
impl FileLockGuard {
pub fn acquire(lock_path: &PathBuf, timeout: Duration) -> Result<Self, LeaderboardError> {
let file = fs::OpenOptions::new()
.create(true)
.write(true)
.open(lock_path)
.map_err(LeaderboardError::IoError)?;
let start_time = Instant::now();
loop {
match file.try_lock_exclusive() {
Ok(()) => {
return Ok(FileLockGuard { _file: file });
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
if start_time.elapsed() > timeout {
return Err(LeaderboardError::LockTimeout);
}
std::thread::sleep(Duration::from_millis(10));
}
Err(e) => {
return Err(LeaderboardError::LockError(format!("Failed to acquire lock: {}", e)));
}
}
}
}
}
impl From<std::io::Error> for LeaderboardError {
fn from(error: std::io::Error) -> Self {
LeaderboardError::IoError(error)
}
}
impl From<serde_json::Error> for LeaderboardError {
fn from(error: serde_json::Error) -> Self {
LeaderboardError::SerializationError(error)
}
}
impl From<ValidationError> for LeaderboardError {
fn from(error: ValidationError) -> Self {
LeaderboardError::ValidationError(error)
}
}
fn create_backup(leaderboard_path: &PathBuf) -> Result<PathBuf, std::io::Error> {
if !leaderboard_path.exists() {
return Ok(leaderboard_path.with_extension("json.bak"));
}
let backup3_path = leaderboard_path.with_extension("json.bak3");
let backup2_path = leaderboard_path.with_extension("json.bak2");
let backup1_path = leaderboard_path.with_extension("json.bak");
if backup3_path.exists() {
fs::remove_file(&backup3_path).ok(); }
if backup2_path.exists() {
fs::rename(&backup2_path, &backup3_path).ok(); }
if backup1_path.exists() {
fs::rename(&backup1_path, &backup2_path).ok(); }
fs::copy(leaderboard_path, &backup1_path)?;
Ok(backup1_path)
}
fn atomic_write(path: &PathBuf, entries: &[LeaderboardEntry]) -> Result<(), LeaderboardError> {
let temp_path = path.with_extension("json.tmp");
let json = serde_json::to_string_pretty(entries)?;
{
let mut temp_file = fs::File::create(&temp_path)?;
temp_file.write_all(json.as_bytes())?;
temp_file.sync_all()?; }
if let Err(_) = validate_json_file(&temp_path) {
fs::remove_file(&temp_path).ok(); return Err(LeaderboardError::IoError(
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Written data failed validation"
)
));
}
fs::rename(&temp_path, path)?;
Ok(())
}
fn validate_json_file(path: &PathBuf) -> Result<(), LeaderboardError> {
if !path.exists() {
return Ok(()); }
let content = fs::read_to_string(path)?;
if content.trim().is_empty() {
return Ok(()); }
let _entries: Vec<LeaderboardEntry> = serde_json::from_str(&content)?;
Ok(())
}
fn recover_from_backup(leaderboard_path: &PathBuf) -> Result<Vec<LeaderboardEntry>, LeaderboardError> {
let backup_files = [
leaderboard_path.with_extension("json.bak"),
leaderboard_path.with_extension("json.bak2"),
leaderboard_path.with_extension("json.bak3"),
];
for (i, backup_path) in backup_files.iter().enumerate() {
if !backup_path.exists() {
continue;
}
eprintln!("Attempting to recover leaderboard from backup {}...", i + 1);
if let Ok(()) = validate_json_file(backup_path) {
let content = fs::read_to_string(backup_path)?;
if content.trim().is_empty() {
eprintln!("Backup {} is empty, trying next backup...", i + 1);
continue;
}
match serde_json::from_str::<Vec<LeaderboardEntry>>(&content) {
Ok(entries) => {
fs::copy(backup_path, leaderboard_path)?;
eprintln!("Successfully recovered leaderboard from backup {}", i + 1);
return Ok(entries);
}
Err(_) => {
eprintln!("Backup {} is corrupted, trying next backup...", i + 1);
continue;
}
}
} else {
eprintln!("Backup {} failed validation, trying next backup...", i + 1);
}
}
eprintln!("No valid backup found, starting with empty leaderboard");
Ok(Vec::new())
}
fn retry_operation<F, T, E>(mut operation: F, max_retries: usize, delay: Duration) -> Result<T, E>
where
F: FnMut() -> Result<T, E>,
E: std::fmt::Debug,
{
let mut last_error = None;
for attempt in 0..=max_retries {
match operation() {
Ok(result) => return Ok(result),
Err(error) => {
if attempt == max_retries {
return Err(error);
}
eprintln!("Operation failed (attempt {}), retrying in {:?}: {:?}",
attempt + 1, delay, error);
last_error = Some(error);
std::thread::sleep(delay);
}
}
}
Err(last_error.unwrap())
}
pub fn get_config_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map_err(|_| "Unable to find home directory")?;
let config_dir = PathBuf::from(home).join(".config").join("typeman");
fs::create_dir_all(&config_dir)?;
Ok(config_dir)
}
pub fn save_entry(entry: &LeaderboardEntry) -> Result<(), LeaderboardError> {
entry.validate()?;
let config_dir = get_config_dir().map_err(|e| LeaderboardError::IoError(
std::io::Error::new(std::io::ErrorKind::Other, e.to_string())
))?;
let leaderboard_path = config_dir.join("leaderboard.json");
let lock_path = config_dir.join("leaderboard.lock");
retry_operation(|| -> Result<(), LeaderboardError> {
let _lock_guard = FileLockGuard::acquire(&lock_path, Duration::from_secs(5))?;
create_backup(&leaderboard_path)?;
let mut entries = load_entries().unwrap_or_default();
entries.push(entry.clone());
entries.sort_by(|a, b| b.wpm.partial_cmp(&a.wpm).unwrap_or(std::cmp::Ordering::Equal));
entries.truncate(100);
atomic_write(&leaderboard_path, &entries)?;
Ok(())
}, 2, Duration::from_millis(100))?;
invalidate_cache();
Ok(())
}
pub fn load_entries() -> Result<Vec<LeaderboardEntry>, LeaderboardError> {
let config_dir = get_config_dir().map_err(|e| LeaderboardError::IoError(
std::io::Error::new(std::io::ErrorKind::Other, e.to_string())
))?;
let leaderboard_path = config_dir.join("leaderboard.json");
if !leaderboard_path.exists() {
return Ok(Vec::new());
}
if let Ok(cache_guard) = get_cache().lock() {
if let Some(ref cache) = *cache_guard {
if cache.is_valid(&leaderboard_path) {
return Ok(cache.entries.clone());
}
}
}
let entries = load_entries_from_file(&leaderboard_path)?;
if let Ok(metadata) = fs::metadata(&leaderboard_path) {
if let Ok(modified) = metadata.modified() {
if let Ok(mut cache_guard) = get_cache().lock() {
*cache_guard = Some(LeaderboardCache {
entries: entries.clone(),
last_modified: modified,
cached_at: Instant::now(),
});
}
}
}
Ok(entries)
}
fn load_entries_from_file(leaderboard_path: &PathBuf) -> Result<Vec<LeaderboardEntry>, LeaderboardError> {
if let Ok(()) = validate_json_file(leaderboard_path) {
let content = fs::read_to_string(leaderboard_path)?;
if content.trim().is_empty() {
return Ok(Vec::new());
}
match serde_json::from_str(&content) {
Ok(entries) => return Ok(entries),
Err(_) => {
eprintln!("Main leaderboard file appears corrupted, attempting recovery...");
}
}
} else {
eprintln!("Main leaderboard file validation failed, attempting recovery...");
}
recover_from_backup(leaderboard_path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_leaderboard_entry_serialization() {
let entry = LeaderboardEntry {
wpm: 85.5,
accuracy: 98.2,
test_type: TestType::Time(30),
test_mode: "time".to_string(),
word_count: 145,
test_duration: 30.0,
timestamp: "2025-09-11T10:30:00Z".to_string(),
language: Language::English,
};
let json = serde_json::to_string(&entry).expect("Should serialize to JSON");
assert!(json.contains("\"wpm\":85.5"));
assert!(json.contains("\"accuracy\":98.2"));
let deserialized: LeaderboardEntry = serde_json::from_str(&json)
.expect("Should deserialize from JSON");
assert_eq!(entry, deserialized);
}
#[test]
fn test_test_type_serialization() {
let time_type = TestType::Time(60);
let word_type = TestType::Word(50);
let quote_type = TestType::Quote;
let practice_type = TestType::Practice(5);
for test_type in [time_type, word_type, quote_type, practice_type] {
let json = serde_json::to_string(&test_type).expect("Should serialize");
let deserialized: TestType = serde_json::from_str(&json)
.expect("Should deserialize");
assert_eq!(test_type, deserialized);
}
}
#[test]
fn test_get_config_dir() {
let result = get_config_dir();
assert!(result.is_ok(), "Should return a valid config directory path");
let path = result.unwrap();
assert!(path.to_string_lossy().contains("typeman"), "Path should contain 'typeman'");
}
#[test]
fn test_save_and_load_entries() {
let entry = LeaderboardEntry {
wpm: 85.5,
accuracy: 98.2,
test_type: TestType::Time(30),
test_mode: "time".to_string(),
word_count: 145,
test_duration: 30.0,
timestamp: "2025-09-11T10:30:00Z".to_string(),
language: Language::English,
};
let save_result = save_entry(&entry);
assert!(save_result.is_ok(), "Should save entry successfully");
let load_result = load_entries();
assert!(load_result.is_ok(), "Should load entries successfully");
let entries = load_result.unwrap();
assert!(!entries.is_empty(), "Should have at least one entry");
let found_entry = entries.iter().find(|e| {
e.wpm == entry.wpm &&
e.accuracy == entry.accuracy &&
e.test_mode == entry.test_mode &&
e.timestamp == entry.timestamp
});
assert!(found_entry.is_some(), "Should find the saved entry in the leaderboard");
assert_eq!(found_entry.unwrap(), &entry, "Loaded entry should match saved entry");
}
#[test]
fn test_load_entries_empty_file() {
let result = load_entries();
assert!(result.is_ok(), "Should handle empty file gracefully");
let entries = result.unwrap();
assert!(entries.is_empty() || !entries.is_empty(), "Should return valid entries vector");
}
}