//! Local data export/import (Phase M — sync transparency). //! //! Bundles every player-owned JSON save (settings, stats, achievements, //! progress) into one versioned backup file so a player can move their //! data between devices without an account, or keep a manual backup. //! //! The bundle deliberately excludes the in-progress game and the replay //! history: the former is transient, the latter can be large and is //! shareable through the replay upload path instead. use std::fs; use std::io; use std::path::{Path, PathBuf}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::settings::Settings; use solitaire_sync::{AchievementRecord, PlayerProgress, StatsSnapshot}; /// Bundle format version. Bump on any breaking shape change; the loader /// rejects newer versions rather than misreading them. pub const DATA_BUNDLE_SCHEMA_VERSION: u32 = 1; /// File name of the default backup location (next to the other saves). pub const DATA_BUNDLE_FILE_NAME: &str = "ferrous-solitaire-backup.json"; /// One-file backup of every player-owned JSON save. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DataBundle { /// Format version — see [`DATA_BUNDLE_SCHEMA_VERSION`]. pub schema_version: u32, /// When the bundle was written. pub exported_at: DateTime, pub settings: Settings, pub stats: StatsSnapshot, pub achievements: Vec, pub progress: PlayerProgress, } /// Why a bundle could not be loaded. #[derive(Debug, thiserror::Error)] pub enum BundleError { #[error("could not read backup file: {0}")] Io(#[from] io::Error), #[error("backup file is not valid JSON: {0}")] Parse(#[from] serde_json::Error), #[error("backup was written by a newer app version (schema {found} > {supported})")] UnsupportedVersion { found: u32, supported: u32 }, } /// Default backup path: `//ferrous-solitaire-backup.json`, /// or `None` when no data directory is available on this platform. pub fn data_bundle_path() -> Option { crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(DATA_BUNDLE_FILE_NAME)) } /// Write `bundle` to `path` atomically (`.tmp` → rename), creating parent /// directories as needed. Mirrors the other save-file writers. pub fn save_bundle_to(path: &Path, bundle: &DataBundle) -> io::Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let json = serde_json::to_string_pretty(bundle).map_err(io::Error::other)?; let tmp = path.with_extension("json.tmp"); fs::write(&tmp, json.as_bytes())?; fs::rename(&tmp, path)?; Ok(()) } /// Read and validate a bundle from `path`. Unlike the per-file loaders this /// does NOT default-on-error — an import silently replacing player data /// with defaults would be destructive, so every failure is surfaced. pub fn load_bundle_from(path: &Path) -> Result { let data = fs::read(path)?; let bundle: DataBundle = serde_json::from_slice(&data)?; if bundle.schema_version > DATA_BUNDLE_SCHEMA_VERSION { return Err(BundleError::UnsupportedVersion { found: bundle.schema_version, supported: DATA_BUNDLE_SCHEMA_VERSION, }); } Ok(bundle) } #[cfg(test)] mod tests { use super::*; fn sample_bundle() -> DataBundle { DataBundle { schema_version: DATA_BUNDLE_SCHEMA_VERSION, exported_at: Utc::now(), settings: Settings::default(), stats: StatsSnapshot { games_played: 7, ..Default::default() }, achievements: vec![], progress: PlayerProgress::default(), } } #[test] fn round_trips_through_disk() { let dir = std::env::temp_dir().join(format!("bundle_test_{}", std::process::id())); let path = dir.join(DATA_BUNDLE_FILE_NAME); let bundle = sample_bundle(); save_bundle_to(&path, &bundle).expect("save should succeed"); let loaded = load_bundle_from(&path).expect("load should succeed"); assert_eq!(loaded.schema_version, DATA_BUNDLE_SCHEMA_VERSION); assert_eq!(loaded.stats.games_played, 7); let _ = fs::remove_dir_all(&dir); } #[test] fn rejects_newer_schema() { let dir = std::env::temp_dir().join(format!("bundle_test_v_{}", std::process::id())); let path = dir.join(DATA_BUNDLE_FILE_NAME); let mut bundle = sample_bundle(); bundle.schema_version = DATA_BUNDLE_SCHEMA_VERSION + 1; save_bundle_to(&path, &bundle).expect("save should succeed"); assert!(matches!( load_bundle_from(&path), Err(BundleError::UnsupportedVersion { .. }) )); let _ = fs::remove_dir_all(&dir); } #[test] fn missing_file_is_an_io_error() { assert!(matches!( load_bundle_from(Path::new("/nonexistent/backup.json")), Err(BundleError::Io(_)) )); } }