2f373784bf
Closes out the 13-phase menu redesign. Three parts:
1. Post-sync merge summary: the pull poller now keeps the
`ConflictReport`s the merge produces (previously discarded) in a new
`SyncConflictLog` resource and toasts "Synced — N conflicts, kept
newer values" when a merge wasn't clean. No wire changes — the
server-side `SyncResponse.conflicts` field already existed.
2. Account tab detail: a per-field conflict list under the sync row
("win_streak_current — this device: 3 / server: 5") plus a
clean-merge caption, so the last merge is always inspectable.
3. Local data export/import: new `solitaire_data::transfer` module —
one versioned JSON bundle (settings, stats, achievements, progress)
written atomically next to the other saves, with a version-gated
loader that surfaces every failure instead of defaulting. Account
tab grows an Export/Import button pair (desktop+Android only); the
handler runs in `Last`, off the annotated Update spine. Import
rewrites the live resources, persists via the existing save fns,
and fires SettingsChangedEvent so appliers react normally.
Tests: bundle round-trip/version-gate/missing-file in solitaire_data;
ECS-level export→import round trip and failed-import-warns in
settings_plugin. Gate: workspace tests green, clippy -D warnings clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
138 lines
4.9 KiB
Rust
138 lines
4.9 KiB
Rust
//! 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<Utc>,
|
|
pub settings: Settings,
|
|
pub stats: StatsSnapshot,
|
|
pub achievements: Vec<AchievementRecord>,
|
|
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: `<data dir>/<app dir>/ferrous-solitaire-backup.json`,
|
|
/// or `None` when no data directory is available on this platform.
|
|
pub fn data_bundle_path() -> Option<PathBuf> {
|
|
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<DataBundle, BundleError> {
|
|
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(_))
|
|
));
|
|
}
|
|
}
|