Files
Ferrous-Solitaire/solitaire_data/src/achievements.rs
T
funman300 67271266e1 refactor(data,core): consolidate APP_DIR_NAME and add #[must_use] on pure fns
- Hoist APP_DIR_NAME = "ferrous_solitaire" to solitaire_data crate root
  as pub(crate); remove 5 duplicate local definitions across achievements,
  progress, settings, storage, replay modules (L-9)
- Add #[must_use] to can_place_on_foundation, can_place_on_tableau, and
  is_valid_tableau_sequence in solitaire_core::rules so callers that
  accidentally discard the result get a compile-time warning (L-6)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:43:47 -07:00

112 lines
3.3 KiB
Rust

//! Persistence for per-player achievement unlock records.
//!
//! The [`AchievementRecord`] struct is defined in `solitaire_sync` so the
//! server can use the same type. This module re-exports it and provides
//! file I/O helpers.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
pub use solitaire_sync::AchievementRecord;
const FILE_NAME: &str = "achievements.json";
/// Platform-specific default path for `achievements.json`.
pub fn achievements_file_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(FILE_NAME))
}
/// Load achievements from an explicit path. Returns `Vec::new()` if the file
/// is missing or unreadable.
pub fn load_achievements_from(path: &Path) -> Vec<AchievementRecord> {
let Ok(data) = fs::read(path) else {
return Vec::new();
};
serde_json::from_slice(&data).unwrap_or_default()
}
/// Save achievements to an explicit path using an atomic write.
pub fn save_achievements_to(path: &Path, records: &[AchievementRecord]) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(records).map_err(io::Error::other)?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, json.as_bytes())?;
fs::rename(&tmp, path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use std::env;
fn tmp_path(name: &str) -> PathBuf {
env::temp_dir().join(format!("solitaire_ach_test_{name}.json"))
}
#[test]
fn unlock_sets_flag_and_date() {
let mut r = AchievementRecord::locked("x");
let at = Utc::now();
r.unlock(at);
assert!(r.unlocked);
assert_eq!(r.unlock_date, Some(at));
}
#[test]
fn unlock_is_idempotent_on_date() {
let mut r = AchievementRecord::locked("x");
let first = Utc::now();
r.unlock(first);
let later = first + chrono::Duration::hours(1);
r.unlock(later);
assert_eq!(r.unlock_date, Some(first), "earliest date preserved");
}
#[test]
fn round_trip_save_and_load() {
let path = tmp_path("round_trip");
let _ = fs::remove_file(&path);
let records = vec![
AchievementRecord::locked("first_win"),
{
let mut r = AchievementRecord::locked("century");
r.unlock(Utc::now());
r
},
];
save_achievements_to(&path, &records).expect("save");
let loaded = load_achievements_from(&path);
assert_eq!(loaded.len(), 2);
assert_eq!(loaded[1].id, "century");
assert!(loaded[1].unlocked);
}
#[test]
fn load_from_missing_file_returns_empty() {
let path = tmp_path("missing_abc");
let _ = fs::remove_file(&path);
assert!(load_achievements_from(&path).is_empty());
}
#[test]
fn load_from_corrupt_file_returns_empty() {
let path = tmp_path("corrupt");
fs::write(&path, b"not json").expect("write");
assert!(load_achievements_from(&path).is_empty());
}
#[test]
fn save_cleans_up_tmp_file() {
let path = tmp_path("atomic");
save_achievements_to(&path, &[]).expect("save");
let tmp = path.with_extension("json.tmp");
assert!(!tmp.exists());
}
}