00f0383867
Save game_state.json on app exit and on pause open so players can resume interrupted sessions. Delete the file on win, loss, or new-game start. Restore the saved game on launch if it exists and isn't won. - solitaire_core: add pile_map_serde module so HashMap<PileType,Pile> round-trips through JSON (serialized as Vec of pairs) - solitaire_data: add game_state_file_path, load_game_state_from, save_game_state_to, delete_game_state_at with 8 new unit tests - solitaire_engine/GamePlugin: restore saved game on startup, expose GameStatePath resource, save on AppExit, delete on new-game and win - solitaire_engine/PausePlugin: save on pause open (guards against OS-level kills while the overlay is showing) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
336 lines
11 KiB
Rust
336 lines
11 KiB
Rust
//! Atomic file I/O for persisted game data.
|
|
//!
|
|
//! All saves go through `filename.json.tmp` → `rename()` so a crash or power
|
|
//! loss during a write never corrupts the saved data.
|
|
|
|
use std::fs;
|
|
use std::io;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use solitaire_core::game_state::GameState;
|
|
|
|
use crate::stats::StatsSnapshot;
|
|
|
|
const APP_DIR_NAME: &str = "solitaire_quest";
|
|
const STATS_FILE_NAME: &str = "stats.json";
|
|
const GAME_STATE_FILE_NAME: &str = "game_state.json";
|
|
|
|
/// Returns the platform-specific path to `stats.json`, or `None` if
|
|
/// `dirs::data_dir()` is unavailable (e.g. minimal Linux containers).
|
|
pub fn stats_file_path() -> Option<PathBuf> {
|
|
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(STATS_FILE_NAME))
|
|
}
|
|
|
|
/// Load stats from an explicit path. Returns `StatsSnapshot::default()` if
|
|
/// the file is missing or cannot be deserialized (corrupt/truncated).
|
|
pub fn load_stats_from(path: &Path) -> StatsSnapshot {
|
|
let Ok(data) = fs::read(path) else {
|
|
return StatsSnapshot::default();
|
|
};
|
|
serde_json::from_slice(&data).unwrap_or_default()
|
|
}
|
|
|
|
/// Save stats to an explicit path using an atomic write (`.tmp` → rename).
|
|
pub fn save_stats_to(path: &Path, stats: &StatsSnapshot) -> io::Result<()> {
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
|
|
let json = serde_json::to_string_pretty(stats).map_err(io::Error::other)?;
|
|
|
|
let tmp = path.with_extension("json.tmp");
|
|
fs::write(&tmp, json.as_bytes())?;
|
|
fs::rename(&tmp, path)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Load stats from the platform default path. Returns default if the path
|
|
/// is unavailable or the file is missing/corrupt.
|
|
pub fn load_stats() -> StatsSnapshot {
|
|
stats_file_path()
|
|
.map(|p| load_stats_from(&p))
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Save stats to the platform default path. Returns an error if the platform
|
|
/// data dir is unavailable or the write fails.
|
|
pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
|
|
let path = stats_file_path().ok_or_else(|| {
|
|
io::Error::new(io::ErrorKind::NotFound, "platform data dir unavailable")
|
|
})?;
|
|
save_stats_to(&path, stats)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// In-progress game state
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Returns the platform-specific path to `game_state.json`, or `None` if
|
|
/// `dirs::data_dir()` is unavailable.
|
|
pub fn game_state_file_path() -> Option<PathBuf> {
|
|
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(GAME_STATE_FILE_NAME))
|
|
}
|
|
|
|
/// Load an in-progress `GameState` from `path`. Returns `None` if the file is
|
|
/// missing, corrupt, or represents a finished game.
|
|
pub fn load_game_state_from(path: &Path) -> Option<GameState> {
|
|
let data = fs::read(path).ok()?;
|
|
let gs: GameState = serde_json::from_slice(&data).ok()?;
|
|
if gs.is_won {
|
|
None
|
|
} else {
|
|
Some(gs)
|
|
}
|
|
}
|
|
|
|
/// Save an in-progress `GameState` atomically. Skips the write if `gs.is_won`
|
|
/// because a completed game should not be resumed.
|
|
pub fn save_game_state_to(path: &Path, gs: &GameState) -> io::Result<()> {
|
|
if gs.is_won {
|
|
return Ok(());
|
|
}
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
let json = serde_json::to_string_pretty(gs).map_err(io::Error::other)?;
|
|
let tmp = path.with_extension("json.tmp");
|
|
fs::write(&tmp, json.as_bytes())?;
|
|
fs::rename(&tmp, path)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete the game state file (called on win, loss, or new-game start).
|
|
/// Silently ignores `NotFound` errors.
|
|
pub fn delete_game_state_at(path: &Path) -> io::Result<()> {
|
|
match fs::remove_file(path) {
|
|
Ok(()) => Ok(()),
|
|
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
/// Remove any leftover `*.json.tmp` files in the app data directory.
|
|
///
|
|
/// These can be left behind if the process crashes between the write and rename
|
|
/// in an atomic save. Safe to call on startup; missing or unreadable entries
|
|
/// are silently skipped.
|
|
pub fn cleanup_orphaned_tmp_files() -> io::Result<()> {
|
|
let dir = match dirs::data_dir() {
|
|
Some(d) => d.join(APP_DIR_NAME),
|
|
None => return Ok(()),
|
|
};
|
|
|
|
if !dir.exists() {
|
|
return Ok(());
|
|
}
|
|
|
|
cleanup_tmp_files_in(&dir);
|
|
Ok(())
|
|
}
|
|
|
|
/// Inner helper: delete `*.json.tmp` entries inside `dir`.
|
|
///
|
|
/// Per-file errors (already deleted, permission denied) are silently ignored.
|
|
fn cleanup_tmp_files_in(dir: &Path) {
|
|
if let Ok(entries) = fs::read_dir(dir) {
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.map(|n| n.ends_with(".json.tmp"))
|
|
.unwrap_or(false)
|
|
{
|
|
let _ = fs::remove_file(&path);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::stats::{StatsExt, StatsSnapshot};
|
|
use solitaire_core::game_state::DrawMode;
|
|
use std::env;
|
|
|
|
fn tmp_path(name: &str) -> PathBuf {
|
|
env::temp_dir().join(format!("solitaire_test_{name}.json"))
|
|
}
|
|
|
|
#[test]
|
|
fn round_trip_save_and_load() {
|
|
let path = tmp_path("round_trip");
|
|
let _ = fs::remove_file(&path);
|
|
|
|
let mut stats = StatsSnapshot::default();
|
|
stats.update_on_win(1000, 180, &DrawMode::DrawOne);
|
|
save_stats_to(&path, &stats).expect("save");
|
|
|
|
let loaded = load_stats_from(&path);
|
|
assert_eq!(loaded.games_won, 1);
|
|
assert_eq!(loaded.best_single_score, 1000);
|
|
assert_eq!(loaded.fastest_win_seconds, 180);
|
|
}
|
|
|
|
#[test]
|
|
fn load_from_missing_file_returns_default() {
|
|
let path = tmp_path("missing_file_abc123");
|
|
let _ = fs::remove_file(&path);
|
|
let stats = load_stats_from(&path);
|
|
assert_eq!(stats, StatsSnapshot::default());
|
|
}
|
|
|
|
#[test]
|
|
fn save_is_atomic_no_half_written_file() {
|
|
let path = tmp_path("atomic_write");
|
|
let stats = StatsSnapshot::default();
|
|
save_stats_to(&path, &stats).expect("save");
|
|
|
|
let tmp = path.with_extension("json.tmp");
|
|
assert!(!tmp.exists(), ".tmp file must be cleaned up after rename");
|
|
}
|
|
|
|
#[test]
|
|
fn load_from_corrupt_file_returns_default() {
|
|
let path = tmp_path("corrupt");
|
|
fs::write(&path, b"not valid json!!!").expect("write corrupt");
|
|
let stats = load_stats_from(&path);
|
|
assert_eq!(stats, StatsSnapshot::default());
|
|
}
|
|
|
|
/// Test the core cleanup logic by creating `.json.tmp` files in a temporary
|
|
/// directory, running the cleanup loop manually, and verifying removal.
|
|
#[test]
|
|
fn cleanup_removes_tmp_files() {
|
|
let dir = env::temp_dir().join("solitaire_cleanup_test");
|
|
fs::create_dir_all(&dir).expect("create test dir");
|
|
|
|
// Create a pair of .json.tmp files and one regular file that must survive.
|
|
let tmp1 = dir.join("stats.json.tmp");
|
|
let tmp2 = dir.join("progress.json.tmp");
|
|
let keep = dir.join("settings.json");
|
|
fs::write(&tmp1, b"orphan1").expect("write tmp1");
|
|
fs::write(&tmp2, b"orphan2").expect("write tmp2");
|
|
fs::write(&keep, b"{}").expect("write keep");
|
|
|
|
// Run the cleanup logic directly against our test directory.
|
|
cleanup_tmp_files_in(&dir);
|
|
|
|
assert!(!tmp1.exists(), "stats.json.tmp should have been removed");
|
|
assert!(!tmp2.exists(), "progress.json.tmp should have been removed");
|
|
assert!(keep.exists(), "settings.json must not be removed");
|
|
|
|
// Tidy up.
|
|
let _ = fs::remove_file(&keep);
|
|
let _ = fs::remove_dir(&dir);
|
|
}
|
|
|
|
/// Calling `cleanup_orphaned_tmp_files` on a box with no app data dir is a
|
|
/// no-op and must not return an error.
|
|
#[test]
|
|
fn cleanup_on_nonexistent_dir_is_ok() {
|
|
// We can't control whether the real app dir exists in the test
|
|
// environment, but the public function must at least not panic or
|
|
// return an Err when the directory is absent.
|
|
// The real implementation returns Ok(()) for missing dirs.
|
|
let result = cleanup_orphaned_tmp_files();
|
|
// The function is allowed to succeed whether or not the dir exists.
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// game_state persistence tests
|
|
// -----------------------------------------------------------------------
|
|
|
|
fn gs_path(name: &str) -> PathBuf {
|
|
env::temp_dir().join(format!("solitaire_test_gs_{name}.json"))
|
|
}
|
|
|
|
#[test]
|
|
fn game_state_round_trip() {
|
|
use solitaire_core::game_state::{DrawMode, GameState};
|
|
let path = gs_path("round_trip");
|
|
let _ = fs::remove_file(&path);
|
|
|
|
let gs = GameState::new(12345, DrawMode::DrawOne);
|
|
save_game_state_to(&path, &gs).expect("save");
|
|
|
|
let loaded = load_game_state_from(&path).expect("load");
|
|
assert_eq!(loaded.seed, gs.seed);
|
|
assert_eq!(loaded.draw_mode, gs.draw_mode);
|
|
assert!(!loaded.is_won);
|
|
}
|
|
|
|
#[test]
|
|
fn load_game_state_missing_file_returns_none() {
|
|
let path = gs_path("missing_xyz");
|
|
let _ = fs::remove_file(&path);
|
|
assert!(load_game_state_from(&path).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn load_game_state_corrupt_file_returns_none() {
|
|
let path = gs_path("corrupt");
|
|
fs::write(&path, b"not valid json!!!").expect("write");
|
|
assert!(load_game_state_from(&path).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn save_game_state_skips_won_games() {
|
|
use solitaire_core::game_state::{DrawMode, GameState};
|
|
let path = gs_path("won_skip");
|
|
let _ = fs::remove_file(&path);
|
|
|
|
let mut gs = GameState::new(99, DrawMode::DrawOne);
|
|
gs.is_won = true;
|
|
save_game_state_to(&path, &gs).expect("save should be no-op, not error");
|
|
assert!(!path.exists(), "should not have written a file for a won game");
|
|
}
|
|
|
|
#[test]
|
|
fn load_game_state_ignores_won_games() {
|
|
use solitaire_core::game_state::{DrawMode, GameState};
|
|
let path = gs_path("won_load");
|
|
let _ = fs::remove_file(&path);
|
|
|
|
// Write a won game directly (bypassing save_game_state_to's guard).
|
|
let mut gs = GameState::new(77, DrawMode::DrawOne);
|
|
gs.is_won = true;
|
|
let json = serde_json::to_string_pretty(&gs).unwrap();
|
|
let tmp = path.with_extension("json.tmp");
|
|
fs::write(&tmp, json.as_bytes()).unwrap();
|
|
fs::rename(&tmp, &path).unwrap();
|
|
|
|
assert!(load_game_state_from(&path).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn delete_game_state_removes_file() {
|
|
use solitaire_core::game_state::{DrawMode, GameState};
|
|
let path = gs_path("delete");
|
|
let gs = GameState::new(1, DrawMode::DrawOne);
|
|
save_game_state_to(&path, &gs).expect("save");
|
|
assert!(path.exists());
|
|
delete_game_state_at(&path).expect("delete");
|
|
assert!(!path.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn delete_game_state_missing_file_is_ok() {
|
|
let path = gs_path("delete_missing");
|
|
let _ = fs::remove_file(&path);
|
|
assert!(delete_game_state_at(&path).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn save_game_state_is_atomic() {
|
|
use solitaire_core::game_state::{DrawMode, GameState};
|
|
let path = gs_path("atomic");
|
|
let gs = GameState::new(55, DrawMode::DrawThree);
|
|
save_game_state_to(&path, &gs).expect("save");
|
|
let tmp = path.with_extension("json.tmp");
|
|
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
|
|
}
|
|
}
|