Import/export feature for config and data
This commit is contained in:
@@ -2,10 +2,14 @@ use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Result, bail};
|
||||
use chrono::Utc;
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
|
||||
use crate::store::schema::{DrillHistoryData, KeyStatsData, ProfileData};
|
||||
use crate::config::Config;
|
||||
use crate::store::schema::{
|
||||
DrillHistoryData, ExportData, KeyStatsData, ProfileData, EXPORT_VERSION,
|
||||
};
|
||||
|
||||
pub struct JsonStore {
|
||||
base_dir: PathBuf,
|
||||
@@ -20,6 +24,12 @@ impl JsonStore {
|
||||
Ok(Self { base_dir })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn with_base_dir(base_dir: PathBuf) -> Result<Self> {
|
||||
fs::create_dir_all(&base_dir)?;
|
||||
Ok(Self { base_dir })
|
||||
}
|
||||
|
||||
fn file_path(&self, name: &str) -> PathBuf {
|
||||
self.base_dir.join(name)
|
||||
}
|
||||
@@ -89,4 +99,295 @@ impl JsonStore {
|
||||
pub fn save_drill_history(&self, data: &DrillHistoryData) -> Result<()> {
|
||||
self.save("lesson_history.json", data)
|
||||
}
|
||||
|
||||
/// Bundle all persisted data + config into an ExportData struct.
|
||||
pub fn export_all(&self, config: &Config) -> ExportData {
|
||||
let profile = self.load_profile().unwrap_or_default();
|
||||
let key_stats = self.load_key_stats();
|
||||
let ranked_key_stats = self.load_ranked_key_stats();
|
||||
let drill_history = self.load_drill_history();
|
||||
|
||||
ExportData {
|
||||
keydr_export_version: EXPORT_VERSION,
|
||||
exported_at: Utc::now(),
|
||||
config: config.clone(),
|
||||
profile,
|
||||
key_stats,
|
||||
ranked_key_stats,
|
||||
drill_history,
|
||||
}
|
||||
}
|
||||
|
||||
/// Transactional import: two-phase commit with best-effort .bak rollback.
|
||||
///
|
||||
/// Stage phase: write all data to .tmp files. If any fails, clean up and bail.
|
||||
/// Commit phase: for each file, rename original to .bak, then .tmp to final.
|
||||
/// On commit failure, attempt to restore .bak files and clean up .tmp files.
|
||||
/// After success, delete .bak files.
|
||||
pub fn import_all(&self, data: &ExportData) -> Result<()> {
|
||||
if data.keydr_export_version != EXPORT_VERSION {
|
||||
bail!(
|
||||
"Unsupported export version: {} (expected {})",
|
||||
data.keydr_export_version,
|
||||
EXPORT_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
let files: Vec<(&str, String)> = vec![
|
||||
("profile.json", serde_json::to_string_pretty(&data.profile)?),
|
||||
("key_stats.json", serde_json::to_string_pretty(&data.key_stats)?),
|
||||
("key_stats_ranked.json", serde_json::to_string_pretty(&data.ranked_key_stats)?),
|
||||
("lesson_history.json", serde_json::to_string_pretty(&data.drill_history)?),
|
||||
];
|
||||
|
||||
// Stage phase: write .tmp files
|
||||
let mut staged: Vec<PathBuf> = Vec::new();
|
||||
for (name, json) in &files {
|
||||
let tmp_path = self.file_path(name).with_extension("json.tmp");
|
||||
match (|| -> Result<()> {
|
||||
let mut file = fs::File::create(&tmp_path)?;
|
||||
file.write_all(json.as_bytes())?;
|
||||
file.sync_all()?;
|
||||
Ok(())
|
||||
})() {
|
||||
Ok(()) => staged.push(tmp_path),
|
||||
Err(e) => {
|
||||
// Clean up staged .tmp files
|
||||
for tmp in &staged {
|
||||
let _ = fs::remove_file(tmp);
|
||||
}
|
||||
bail!("Import failed during staging: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Commit phase: .bak then rename .tmp to final
|
||||
// Track (final_path, had_original) so rollback can restore absence
|
||||
let mut committed: Vec<(PathBuf, PathBuf, bool)> = Vec::new();
|
||||
for (i, (name, _)) in files.iter().enumerate() {
|
||||
let final_path = self.file_path(name);
|
||||
let bak_path = self.file_path(name).with_extension("json.bak");
|
||||
let tmp_path = &staged[i];
|
||||
let had_original = final_path.exists();
|
||||
|
||||
// Back up existing file if it exists
|
||||
if had_original
|
||||
&& let Err(e) = fs::rename(&final_path, &bak_path)
|
||||
{
|
||||
// Rollback: restore already committed files
|
||||
for (committed_final, committed_bak, committed_had) in &committed {
|
||||
if *committed_had {
|
||||
let _ = fs::rename(committed_bak, committed_final);
|
||||
} else {
|
||||
let _ = fs::remove_file(committed_final);
|
||||
}
|
||||
}
|
||||
// Clean up all .tmp files
|
||||
for tmp in &staged {
|
||||
let _ = fs::remove_file(tmp);
|
||||
}
|
||||
bail!("Import failed during commit (backup): {e}");
|
||||
}
|
||||
|
||||
// Rename .tmp to final
|
||||
if let Err(e) = fs::rename(tmp_path, &final_path) {
|
||||
// Restore this file's backup or remove if it didn't exist
|
||||
if had_original && bak_path.exists() {
|
||||
let _ = fs::rename(&bak_path, &final_path);
|
||||
} else {
|
||||
let _ = fs::remove_file(&final_path);
|
||||
}
|
||||
// Rollback previously committed files
|
||||
for (committed_final, committed_bak, committed_had) in &committed {
|
||||
if *committed_had {
|
||||
let _ = fs::rename(committed_bak, committed_final);
|
||||
} else {
|
||||
let _ = fs::remove_file(committed_final);
|
||||
}
|
||||
}
|
||||
// Clean up remaining .tmp files
|
||||
for tmp in &staged[i + 1..] {
|
||||
let _ = fs::remove_file(tmp);
|
||||
}
|
||||
bail!("Import failed during commit (rename): {e}");
|
||||
}
|
||||
|
||||
committed.push((final_path, bak_path, had_original));
|
||||
}
|
||||
|
||||
// Success: clean up .bak files
|
||||
for (_, bak_path, had_original) in &committed {
|
||||
if *had_original {
|
||||
let _ = fs::remove_file(bak_path);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check for leftover .bak files from an interrupted import.
|
||||
/// Returns true if recovery files were found (and cleaned up).
|
||||
pub fn check_interrupted_import(&self) -> bool {
|
||||
let bak_names = [
|
||||
"profile.json.bak",
|
||||
"key_stats.json.bak",
|
||||
"key_stats_ranked.json.bak",
|
||||
"lesson_history.json.bak",
|
||||
];
|
||||
let mut found = false;
|
||||
for name in &bak_names {
|
||||
let bak_path = self.base_dir.join(name);
|
||||
if bak_path.exists() {
|
||||
found = true;
|
||||
let _ = fs::remove_file(&bak_path);
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::store::schema::EXPORT_VERSION;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_test_store() -> (TempDir, JsonStore) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store = JsonStore::with_base_dir(dir.path().to_path_buf()).unwrap();
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
fn make_test_export(config: &Config) -> ExportData {
|
||||
ExportData {
|
||||
keydr_export_version: EXPORT_VERSION,
|
||||
exported_at: Utc::now(),
|
||||
config: config.clone(),
|
||||
profile: ProfileData::default(),
|
||||
key_stats: KeyStatsData::default(),
|
||||
ranked_key_stats: KeyStatsData::default(),
|
||||
drill_history: DrillHistoryData::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_round_trip_export_import() {
|
||||
let (_dir, store) = make_test_store();
|
||||
let config = Config::default();
|
||||
|
||||
// Save some initial data
|
||||
store.save_profile(&ProfileData::default()).unwrap();
|
||||
|
||||
let export = store.export_all(&config);
|
||||
assert_eq!(export.keydr_export_version, EXPORT_VERSION);
|
||||
|
||||
// Create a second store and import into it
|
||||
let (_dir2, store2) = make_test_store();
|
||||
store2.import_all(&export).unwrap();
|
||||
|
||||
// Verify data matches
|
||||
let imported_profile = store2.load_profile().unwrap();
|
||||
assert_eq!(imported_profile.total_drills, export.profile.total_drills);
|
||||
assert!((imported_profile.total_score - export.profile.total_score).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_rejection() {
|
||||
let (_dir, store) = make_test_store();
|
||||
let config = Config::default();
|
||||
let mut export = make_test_export(&config);
|
||||
export.keydr_export_version = 99;
|
||||
|
||||
let result = store.import_all(&export);
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(err_msg.contains("Unsupported export version"));
|
||||
assert!(err_msg.contains("99"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_clamps_values() {
|
||||
let mut config = Config::default();
|
||||
config.target_wpm = 0;
|
||||
config.word_count = 999;
|
||||
config.code_language = "nonexistent".to_string();
|
||||
|
||||
let valid_keys = vec!["rust", "python", "javascript"];
|
||||
config.validate(&valid_keys);
|
||||
|
||||
assert_eq!(config.target_wpm, 10);
|
||||
assert_eq!(config.word_count, 100);
|
||||
assert_eq!(config.code_language, "rust"); // falls back to default
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_staging_failure_preserves_originals() {
|
||||
let (_dir, store) = make_test_store();
|
||||
|
||||
// Save known good data
|
||||
let mut profile = ProfileData::default();
|
||||
profile.total_drills = 42;
|
||||
store.save_profile(&profile).unwrap();
|
||||
let original_content = fs::read_to_string(store.file_path("profile.json")).unwrap();
|
||||
|
||||
// Now create a store that points to a nonexistent subdir of the same tmpdir
|
||||
// so that staging .tmp writes will fail
|
||||
let bad_dir = _dir.path().join("nonexistent_subdir");
|
||||
let bad_store = JsonStore { base_dir: bad_dir.clone() };
|
||||
let config = Config::default();
|
||||
let export = make_test_export(&config);
|
||||
let result = bad_store.import_all(&export);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Import failed during staging"));
|
||||
|
||||
// Original file in the real store is unchanged
|
||||
let after_content = fs::read_to_string(store.file_path("profile.json")).unwrap();
|
||||
assert_eq!(original_content, after_content);
|
||||
|
||||
// No .tmp files left in the bad dir (dir doesn't exist, so nothing to clean)
|
||||
assert!(!bad_dir.exists());
|
||||
|
||||
// No .tmp files left in the real store dir either
|
||||
let tmp_files: Vec<_> = fs::read_dir(_dir.path())
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("tmp"))
|
||||
.collect();
|
||||
assert!(tmp_files.is_empty(), "no residual .tmp files");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_into_empty_store_then_verify_files_created() {
|
||||
let (_dir, store) = make_test_store();
|
||||
|
||||
// No files initially
|
||||
assert!(!store.file_path("profile.json").exists());
|
||||
|
||||
let config = Config::default();
|
||||
let export = make_test_export(&config);
|
||||
store.import_all(&export).unwrap();
|
||||
|
||||
// All files should now exist
|
||||
assert!(store.file_path("profile.json").exists());
|
||||
assert!(store.file_path("key_stats.json").exists());
|
||||
assert!(store.file_path("key_stats_ranked.json").exists());
|
||||
assert!(store.file_path("lesson_history.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_interrupted_import_detects_bak_files() {
|
||||
let (_dir, store) = make_test_store();
|
||||
|
||||
// No .bak files initially
|
||||
assert!(!store.check_interrupted_import());
|
||||
|
||||
// Create a .bak file
|
||||
fs::write(store.file_path("profile.json.bak"), "{}").unwrap();
|
||||
assert!(store.check_interrupted_import());
|
||||
|
||||
// Should have been cleaned up
|
||||
assert!(!store.file_path("profile.json.bak").exists());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user