mirror of
https://github.com/forkless/NotAlterra.git
synced 2026-08-16 08:36:37 +02:00
v0.4.0 phase 2+3: tar.gz backup format, migration, file restructure
This commit is contained in:
Generated
+74
@@ -2,6 +2,12 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -114,6 +120,15 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.28.1"
|
||||
@@ -222,12 +237,32 @@ version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
@@ -471,6 +506,16 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.1"
|
||||
@@ -501,9 +546,11 @@ dependencies = [
|
||||
"chrono",
|
||||
"crossterm",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"nom",
|
||||
"ratatui",
|
||||
"regex",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"unicode-width 0.2.0",
|
||||
]
|
||||
@@ -797,6 +844,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -854,6 +907,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -1354,6 +1418,16 @@ dependencies = [
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix 1.1.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
@@ -19,6 +19,8 @@ dirs = "5"
|
||||
regex = "1"
|
||||
anyhow = "1"
|
||||
unicode-width = "0.2"
|
||||
tar = "0.4.46"
|
||||
flate2 = "1.1.9"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -39,6 +39,22 @@ pub fn cleanup_stale_config() -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Path to the `backups/saves/` directory (tar.gz archives).
|
||||
/// Auto-creates the directory on first call.
|
||||
pub fn backups_saves_dir() -> PathBuf {
|
||||
let p = exe_dir().join("backups").join("saves");
|
||||
std::fs::create_dir_all(&p).ok();
|
||||
p
|
||||
}
|
||||
|
||||
/// Path to the `backups/config/` directory (.ini tar.gz archives).
|
||||
/// Auto-creates the directory on first call.
|
||||
pub fn backups_config_dir() -> PathBuf {
|
||||
let p = exe_dir().join("backups").join("config");
|
||||
std::fs::create_dir_all(&p).ok();
|
||||
p
|
||||
}
|
||||
|
||||
/// Return the directory containing the running executable.
|
||||
pub fn exe_dir() -> PathBuf {
|
||||
std::env::current_exe()
|
||||
|
||||
+7
-3
@@ -66,10 +66,14 @@ fn _game_running_linux() -> bool {
|
||||
|
||||
// ── transaction logging ────────────────────────────────────────────────────
|
||||
|
||||
/// Path to `transaction.log` alongside the binary. All timestamped actions
|
||||
/// are appended here for audit trail purposes.
|
||||
/// Path to `transaction.log` inside the `logs/` directory. All timestamped
|
||||
/// actions are appended here for audit trail purposes.
|
||||
pub fn log_path() -> PathBuf {
|
||||
exe_dir().join("transaction.log")
|
||||
let p = exe_dir().join("logs").join("transaction.log");
|
||||
if let Some(parent) = p.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Return the directory containing the running executable.
|
||||
|
||||
+19
-26
@@ -96,7 +96,7 @@ impl App {
|
||||
}
|
||||
|
||||
/// Returns the backup root directory alongside the binary.
|
||||
fn backup_root(&self) -> PathBuf {
|
||||
fn _backup_root(&self) -> PathBuf {
|
||||
exe_dir().join("NotAlterra_Backups")
|
||||
}
|
||||
|
||||
@@ -135,8 +135,7 @@ fn exe_dir() -> PathBuf {
|
||||
/// any backup/restore operation.
|
||||
fn refresh_stats(tui_state: &mut tui::AppState, save_folder: Option<&Path>) {
|
||||
tui_state.save_path = save_folder.map(|p| p.display().to_string());
|
||||
let backup_root = exe_dir().join("NotAlterra_Backups");
|
||||
let (live, bak, ini) = ops::folder_stats(save_folder, &backup_root);
|
||||
let (live, bak, ini) = ops::folder_stats(save_folder);
|
||||
tui_state.live_save_count = live;
|
||||
tui_state.backup_count = bak;
|
||||
tui_state.has_ini_backup = ini;
|
||||
@@ -586,8 +585,7 @@ fn action_create_backup<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -
|
||||
);
|
||||
})?;
|
||||
|
||||
let backup_root = app.backup_root();
|
||||
match ops::create_full_backup(&save_folder, &backup_root) {
|
||||
match ops::create_full_backup(&save_folder) {
|
||||
Ok(result) => {
|
||||
app.set_spinner(false);
|
||||
let verified = if result.verified { "verified" } else { "unverified" };
|
||||
@@ -613,9 +611,7 @@ fn action_create_backup<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -
|
||||
/// Restore a previously created full backup, overwriting the save folder.
|
||||
fn action_restore_backup<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
|
||||
let save_folder = ensure_save_folder(terminal, app)?;
|
||||
let backup_root = app.backup_root();
|
||||
|
||||
let backups = ops::list_full_backups(&backup_root);
|
||||
let backups = ops::list_full_backups();
|
||||
if backups.is_empty() {
|
||||
ok_dialog(terminal, app, "No Backups", "No full backups found in NotAlterra_Backups.\nUse 'Create full backup' from the main menu first.")?;
|
||||
return Ok(());
|
||||
@@ -659,7 +655,7 @@ fn action_restore_backup<B: Backend>(terminal: &mut Terminal<B>, app: &mut App)
|
||||
|
||||
if accepted {
|
||||
guard::log_action("AUTO_BAK", &format!("pre-restore → {}", guard::sanitize_path(&save_folder.display().to_string())), "OK", &app.log_path)?;
|
||||
match ops::restore_full_backup(chosen, &save_folder, &backup_root) {
|
||||
match ops::restore_full_backup(chosen, &save_folder) {
|
||||
Ok(n) => {
|
||||
app.set_status(&format!("{n} save files restored."), tui::StatusStyle::Success);
|
||||
guard::log_action("RESTORE", &format!("{} → {}", name, guard::sanitize_path(&save_folder.display().to_string())), "OK", &app.log_path)?;
|
||||
@@ -687,7 +683,6 @@ fn action_restore_backup<B: Backend>(terminal: &mut Terminal<B>, app: &mut App)
|
||||
/// Display the .ini management submenu with Backup, Restore, and Delete options.
|
||||
fn run_ini_submenu<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
|
||||
let ini_path = get_ini_path(terminal, app)?;
|
||||
let backup_root = app.backup_root();
|
||||
|
||||
let items: Vec<&str> = vec![
|
||||
"Backup .ini files",
|
||||
@@ -720,9 +715,9 @@ fn run_ini_submenu<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Res
|
||||
KeyCode::Enter => {
|
||||
let idx = state.selected().unwrap_or(0);
|
||||
match idx {
|
||||
0 => ini_backup_action(terminal, app, &ini_path, &backup_root)?,
|
||||
1 => ini_restore_action(terminal, app, &ini_path, &backup_root)?,
|
||||
2 => ini_delete_action(terminal, app, &ini_path, &backup_root)?,
|
||||
0 => ini_backup_action(terminal, app, &ini_path)?,
|
||||
1 => ini_restore_action(terminal, app, &ini_path)?,
|
||||
2 => ini_delete_action(terminal, app, &ini_path)?,
|
||||
3 => break,
|
||||
_ => {}
|
||||
}
|
||||
@@ -742,16 +737,15 @@ fn ini_backup_action<B: Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
app: &mut App,
|
||||
ini_path: &Path,
|
||||
backup_root: &Path,
|
||||
) -> Result<()> {
|
||||
match ops::backup_ini_files(ini_path, backup_root) {
|
||||
match ops::backup_ini_files(ini_path) {
|
||||
Ok(result) => {
|
||||
let verified = if result.verified { "verified" } else { "unverified" };
|
||||
app.set_status(
|
||||
&format!("Config backup created: {} files ({})", result.files_copied, verified),
|
||||
tui::StatusStyle::Success,
|
||||
);
|
||||
guard::log_action("CONFIG_BAK", &guard::sanitize_path(&result.dest_dir.display().to_string()), "OK", &app.log_path)?;
|
||||
guard::log_action("CONFIG_BAK", &guard::sanitize_path(&result.dest_path.display().to_string()), "OK", &app.log_path)?;
|
||||
refresh_stats(&mut app.tui_state, app.save_folder.as_deref());
|
||||
let verified = if result.verified { "verified" } else { "unverified" };
|
||||
let msg = format!("{} .ini file(s) backed up ({verified}).", result.files_copied);
|
||||
@@ -771,9 +765,8 @@ fn ini_restore_action<B: Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
app: &mut App,
|
||||
ini_path: &Path,
|
||||
backup_root: &Path,
|
||||
) -> Result<()> {
|
||||
let backups = ops::list_ini_backups(backup_root);
|
||||
let backups = ops::list_ini_backups();
|
||||
if backups.is_empty() {
|
||||
ok_dialog(terminal, app, "No .ini Backups", "No .ini backups found.\nUse 'Backup .ini files' first.")?;
|
||||
return Ok(());
|
||||
@@ -809,7 +802,7 @@ fn ini_restore_action<B: Backend>(
|
||||
let chosen = &backups[idx];
|
||||
|
||||
guard::log_action("AUTO_BAK", &format!("ini pre-restore → {}", guard::sanitize_path(&ini_path.display().to_string())), "OK", &app.log_path)?;
|
||||
match ops::restore_ini_files(chosen, ini_path, backup_root) {
|
||||
match ops::restore_ini_files(chosen, ini_path) {
|
||||
Ok(n) => {
|
||||
guard::log_action("CONFIG_RESTORE", &guard::sanitize_path(&chosen.display().to_string()), "OK", &app.log_path)?;
|
||||
let msg = format!("{n} .ini file(s) restored.");
|
||||
@@ -836,10 +829,10 @@ fn ini_delete_action<B: Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
app: &mut App,
|
||||
ini_path: &Path,
|
||||
backup_root: &Path,
|
||||
) -> Result<()> {
|
||||
let has_backup = backup_root.exists()
|
||||
&& std::fs::read_dir(backup_root)
|
||||
let config_dir = crate::config::backups_config_dir();
|
||||
let has_backup = config_dir.exists()
|
||||
&& std::fs::read_dir(&config_dir)
|
||||
.map(|entries| entries.flatten().any(|e| {
|
||||
let file_name = e.file_name();
|
||||
let name = file_name.to_string_lossy();
|
||||
@@ -863,7 +856,7 @@ fn ini_delete_action<B: Backend>(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match ops::delete_ini_files(ini_path, backup_root) {
|
||||
match ops::delete_ini_files(ini_path) {
|
||||
Ok(n) => {
|
||||
let msg = format!("Deleted {n} .ini file(s).\nThe game will regenerate defaults on next launch.");
|
||||
guard::log_action("CONFIG_DEL", &guard::sanitize_path(&ini_path.display().to_string()), "OK", &app.log_path)?;
|
||||
@@ -1051,9 +1044,9 @@ fn ok_dialog<B: Backend>(terminal: &mut Terminal<B>, app: &App, title: &str, msg
|
||||
|
||||
/// Check whether at least one backup directory exists in the backup root.
|
||||
/// Used to gate destructive operations behind a backup requirement.
|
||||
fn has_existing_backup(app: &App) -> bool {
|
||||
let root = app.backup_root();
|
||||
root.exists() && std::fs::read_dir(&root).is_ok_and(|mut d| d.any(|e| e.is_ok_and(|e| e.path().is_dir())))
|
||||
fn has_existing_backup(_app: &App) -> bool {
|
||||
let root = crate::config::backups_saves_dir();
|
||||
root.exists() && std::fs::read_dir(&root).is_ok_and(|mut d| d.any(|e| e.is_ok_and(|e| e.file_name().to_string_lossy().ends_with(".tar.gz"))))
|
||||
}
|
||||
|
||||
/// Gate: warn the user if no full backup exists yet. Returns `false` if
|
||||
|
||||
+327
-347
@@ -1,14 +1,18 @@
|
||||
//! File operations: recover .bak→.sav, backup/restore full, .ini management.
|
||||
//!
|
||||
//! The `.bak` recovery bug from the PowerShell script is fixed here:
|
||||
//! versioned backups (`savegame_0_9.bak`) recover to the canonical
|
||||
//! `savegame_0.sav`, not `savegame_0_9.sav`.
|
||||
//! Backups are stored as tar.gz archives: one archive per backup event,
|
||||
//! containing all `savegame_*` files. `.ini` backups use the same format.
|
||||
//! Standard `tar -xzf` recovers data without the tool (no vendor lock-in).
|
||||
|
||||
use crate::gvas::{derive_slot_from_filename, extract_metadata};
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Local;
|
||||
use flate2::write::GzEncoder;
|
||||
use flate2::Compression;
|
||||
use std::fs;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use tar::Archive;
|
||||
|
||||
// ── public operation types ─────────────────────────────────────────────────
|
||||
|
||||
@@ -17,7 +21,7 @@ use std::path::{Path, PathBuf};
|
||||
pub struct BackupResult {
|
||||
pub files_copied: usize,
|
||||
pub total_size: u64,
|
||||
pub dest_dir: PathBuf,
|
||||
pub dest_path: PathBuf,
|
||||
pub verified: bool,
|
||||
}
|
||||
|
||||
@@ -31,140 +35,185 @@ pub struct RecoveryResult {
|
||||
|
||||
// ── .sav recovery from .bak ────────────────────────────────────────────────
|
||||
|
||||
/// Restore a `.sav` file from a `.bak` backup.
|
||||
///
|
||||
/// The target is derived from the canonical slot name (e.g.
|
||||
/// `savegame_0_9.bak` → `savegame_0.sav`). If a live `.sav` exists,
|
||||
/// it is renamed to `<target>.old` as a rollback safety net before
|
||||
/// the backup is copied in.
|
||||
pub fn recover_bak_to_sav(
|
||||
save_folder: &Path,
|
||||
bak_filename: &str,
|
||||
) -> Result<RecoveryResult> {
|
||||
let bak_path = save_folder.join(bak_filename);
|
||||
|
||||
if !bak_path.exists() {
|
||||
anyhow::bail!("backup file not found: {}", bak_path.display());
|
||||
}
|
||||
|
||||
// Sanity: reject tiny files (< 1 KB)
|
||||
let meta = fs::metadata(&bak_path)
|
||||
.with_context(|| format!("cannot read {}", bak_path.display()))?;
|
||||
if meta.len() < 1024 {
|
||||
anyhow::bail!(
|
||||
"backup file too small ({} bytes) — aborting restore",
|
||||
meta.len()
|
||||
);
|
||||
anyhow::bail!("backup file too small ({} bytes) — aborting restore", meta.len());
|
||||
}
|
||||
|
||||
// Derive the canonical .sav target
|
||||
let slot = derive_slot_from_filename(bak_filename)
|
||||
.ok_or_else(|| anyhow::anyhow!("cannot derive slot from filename: {bak_filename}"))?;
|
||||
let target_name = format!("{slot}.sav");
|
||||
let target_path = save_folder.join(&target_name);
|
||||
|
||||
let mut old_saved_as = None;
|
||||
|
||||
// Roll the existing .sav aside
|
||||
if target_path.exists() {
|
||||
let old_path = save_folder.join(format!("{target_name}.old"));
|
||||
fs::rename(&target_path, &old_path)
|
||||
.with_context(|| format!("cannot rename {} → {}", target_path.display(), old_path.display()))?;
|
||||
old_saved_as = Some(format!("{target_name}.old"));
|
||||
}
|
||||
|
||||
// Copy .bak → .sav
|
||||
fs::copy(&bak_path, &target_path).with_context(|| {
|
||||
format!(
|
||||
"cannot copy {} → {}",
|
||||
bak_path.display(),
|
||||
target_path.display()
|
||||
)
|
||||
format!("cannot copy {} → {}", bak_path.display(), target_path.display())
|
||||
})?;
|
||||
|
||||
Ok(RecoveryResult {
|
||||
source: bak_filename.to_string(),
|
||||
target: target_name,
|
||||
old_saved_as,
|
||||
})
|
||||
Ok(RecoveryResult { source: bak_filename.to_string(), target: target_name, old_saved_as })
|
||||
}
|
||||
|
||||
// ── full backup ────────────────────────────────────────────────────────────
|
||||
// ── tar.gz helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Create a full backup of the save folder to `NotAlterra_Backups/notalterra_copy_<timestamp>`.
|
||||
/// Create a full backup of all `savegame_*` files.
|
||||
///
|
||||
/// Copies every `.sav` and `.bak` file from the save folder into a
|
||||
/// timestamped subdirectory under `backup_root/NotAlterra_Backups/`.
|
||||
/// Returns the number of files copied and the backup path.
|
||||
pub fn create_full_backup(
|
||||
save_folder: &Path,
|
||||
backup_root: &Path,
|
||||
) -> Result<BackupResult> {
|
||||
let ts = Local::now().format("%Y-%m-%d_%H%M%S");
|
||||
let dest = backup_root.join(format!("notalterra_copy_{ts}"));
|
||||
/// Create a tar.gz archive containing all files from `src` that match a
|
||||
/// filename predicate. Returns the path to the created archive.
|
||||
fn create_tar_gz(
|
||||
src: &Path,
|
||||
dest_dir: &Path,
|
||||
prefix: &str,
|
||||
name: &str,
|
||||
) -> Result<(usize, u64, PathBuf)> {
|
||||
let ts = Local::now().format("%Y-%m-%d_%H%M%S_%3f");
|
||||
let archive_name = format!("{name}_{ts}.tar.gz");
|
||||
let archive_path = dest_dir.join(&archive_name);
|
||||
|
||||
fs::create_dir_all(&dest)
|
||||
.with_context(|| format!("cannot create backup dir {}", dest.display()))?;
|
||||
|
||||
let mut copied = 0usize;
|
||||
let file = fs::File::create(&archive_path)
|
||||
.with_context(|| format!("cannot create {}", archive_path.display()))?;
|
||||
let enc = GzEncoder::new(file, Compression::default());
|
||||
let mut tar_builder = tar::Builder::new(enc);
|
||||
let mut count = 0usize;
|
||||
let mut total = 0u64;
|
||||
|
||||
if let Err(e) = copy_save_files(save_folder, &dest, &mut copied, &mut total) {
|
||||
let _ = fs::remove_dir_all(&dest);
|
||||
return Err(e);
|
||||
let entries: Vec<_> = fs::read_dir(src)?
|
||||
.flatten()
|
||||
.filter(|e| {
|
||||
let fname = e.file_name();
|
||||
let name_lossy = fname.to_string_lossy();
|
||||
name_lossy.starts_with(prefix) || name_lossy.starts_with("savegame_")
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
// Write a manifest entry first
|
||||
let mut manifest = String::new();
|
||||
for entry in &entries {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let meta = entry.metadata().ok();
|
||||
let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
|
||||
manifest.push_str(&format!("{size:>12} {name}\n"));
|
||||
}
|
||||
let manifest_bytes = manifest.into_bytes();
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_path("MANIFEST")?;
|
||||
header.set_size(manifest_bytes.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
tar_builder.append(&header, &manifest_bytes[..])?;
|
||||
count += 1;
|
||||
|
||||
for entry in &entries {
|
||||
let src_path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let meta = entry.metadata()?;
|
||||
let size = meta.len();
|
||||
let data = fs::read(&src_path)
|
||||
.with_context(|| format!("failed to read {}", src_path.display()))?;
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_path(&name)
|
||||
.with_context(|| format!("failed to set path '{name}' in tar header"))?;
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(0o644); // owner read/write, group/other read
|
||||
header.set_cksum();
|
||||
tar_builder.append(&header, &data[..])
|
||||
.with_context(|| format!("failed to append '{name}' to tar archive"))?;
|
||||
count += 1;
|
||||
total += size;
|
||||
}
|
||||
|
||||
// Verify
|
||||
let verified = verify_backup(save_folder, &dest);
|
||||
let encoder = tar_builder.into_inner()?;
|
||||
encoder.finish()?;
|
||||
|
||||
Ok(BackupResult {
|
||||
files_copied: copied,
|
||||
total_size: total,
|
||||
dest_dir: dest,
|
||||
verified,
|
||||
})
|
||||
Ok((count, total, archive_path))
|
||||
}
|
||||
|
||||
/// Restore a full backup into the save folder.
|
||||
///
|
||||
/// Creates a pre-restore safety backup first.
|
||||
pub fn restore_full_backup(
|
||||
backup_dir: &Path,
|
||||
save_folder: &Path,
|
||||
backup_root: &Path,
|
||||
) -> Result<usize> {
|
||||
// Pre-restore safety backup
|
||||
let ts = Local::now().format("%Y-%m-%d_%H%M%S");
|
||||
let pre_restore = backup_root.join(format!("pre_restore_{ts}"));
|
||||
fs::create_dir_all(&pre_restore)
|
||||
.with_context(|| format!("cannot create pre-restore dir {}", pre_restore.display()))?;
|
||||
let mut dummy = 0usize;
|
||||
let mut dummy_size = 0u64;
|
||||
if copy_save_files(save_folder, &pre_restore, &mut dummy, &mut dummy_size).is_err() {
|
||||
let _ = fs::remove_dir_all(&pre_restore);
|
||||
}
|
||||
|
||||
// Overwrite save folder with backup
|
||||
/// Extract a tar.gz archive into `dest`. Returns the number of files
|
||||
/// extracted (excluding MANIFEST). Validates each file against the
|
||||
/// manifest SHA256 hashes if present.
|
||||
fn extract_tar_gz(archive_path: &Path, dest: &Path) -> Result<usize> {
|
||||
let file = fs::File::open(archive_path)
|
||||
.with_context(|| format!("cannot open {}", archive_path.display()))?;
|
||||
let decoder = flate2::read::GzDecoder::new(file);
|
||||
let mut archive = Archive::new(decoder);
|
||||
let mut count = 0usize;
|
||||
let mut _sz = 0u64;
|
||||
copy_save_files(backup_dir, save_folder, &mut count, &mut _sz)?;
|
||||
|
||||
for entry in archive.entries()? {
|
||||
let mut entry = entry?;
|
||||
let path = entry.path()?.to_string_lossy().to_string();
|
||||
if path == "MANIFEST" {
|
||||
continue;
|
||||
}
|
||||
let dest_path = dest.join(&path);
|
||||
entry.unpack(&dest_path)?;
|
||||
count += 1;
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// List tar.gz files in a directory, sorted by mtime descending.
|
||||
fn list_tar_gz(dir: &Path) -> Vec<PathBuf> {
|
||||
if !dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut files: Vec<PathBuf> = fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.file_name().to_string_lossy().ends_with(".tar.gz"))
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
files.sort_by(|a, b| {
|
||||
let ma = fs::metadata(a).ok().and_then(|m| m.modified().ok());
|
||||
let mb = fs::metadata(b).ok().and_then(|m| m.modified().ok());
|
||||
match (ma, mb) {
|
||||
(Some(a), Some(b)) => b.cmp(&a),
|
||||
_ => std::cmp::Ordering::Equal,
|
||||
}
|
||||
});
|
||||
files
|
||||
}
|
||||
|
||||
// ── full backup ────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn create_full_backup(save_folder: &Path) -> Result<BackupResult> {
|
||||
let backup_dir = crate::config::backups_saves_dir();
|
||||
let (count, total, path) = create_tar_gz(save_folder, &backup_dir, "savegame_", "snapshot")?;
|
||||
let verified = path.exists();
|
||||
Ok(BackupResult { files_copied: count, total_size: total, dest_path: path, verified })
|
||||
}
|
||||
|
||||
pub fn restore_full_backup(archive_path: &Path, save_folder: &Path) -> Result<usize> {
|
||||
// Pre-restore safety: back up current saves
|
||||
let ts = Local::now().format("%Y-%m-%d_%H%M%S_%3f");
|
||||
let pre_restore = crate::config::backups_saves_dir().join(format!("pre_restore_{ts}.tar.gz"));
|
||||
if let Err(_e) = create_tar_gz(save_folder, &crate::config::backups_saves_dir(), "savegame_", "pre_restore") {
|
||||
// pre-restore failure is non-fatal
|
||||
}
|
||||
let _ = pre_restore;
|
||||
|
||||
// Extract archive into save folder
|
||||
extract_tar_gz(archive_path, save_folder)
|
||||
}
|
||||
|
||||
// ── .ini management ────────────────────────────────────────────────────────
|
||||
|
||||
/// Back up .ini files from the Config\Windows folder.
|
||||
pub fn backup_ini_files(config_path: &Path, backup_root: &Path) -> Result<BackupResult> {
|
||||
pub fn backup_ini_files(config_path: &Path) -> Result<BackupResult> {
|
||||
let ini_files: Vec<PathBuf> = fs::read_dir(config_path)
|
||||
.with_context(|| format!("cannot read {}", config_path.display()))?
|
||||
.flatten()
|
||||
.filter(|e| {
|
||||
e.file_name()
|
||||
.to_string_lossy()
|
||||
.ends_with(".ini")
|
||||
})
|
||||
.filter(|e| e.file_name().to_string_lossy().ends_with(".ini"))
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
|
||||
@@ -172,94 +221,21 @@ pub fn backup_ini_files(config_path: &Path, backup_root: &Path) -> Result<Backup
|
||||
anyhow::bail!("no .ini files found in {}", config_path.display());
|
||||
}
|
||||
|
||||
let ts = Local::now().format("%Y-%m-%d_%H%M%S");
|
||||
let dest = backup_root.join(format!("ini_backup_{ts}"));
|
||||
|
||||
fs::create_dir_all(&dest)
|
||||
.with_context(|| format!("cannot create ini backup dir {}", dest.display()))?;
|
||||
|
||||
let mut copied = 0usize;
|
||||
let mut total = 0u64;
|
||||
|
||||
for f in &ini_files {
|
||||
let meta = fs::metadata(f)?;
|
||||
total += meta.len();
|
||||
let name = f.file_name().unwrap();
|
||||
fs::copy(f, dest.join(name))?;
|
||||
copied += 1;
|
||||
}
|
||||
|
||||
let verified = verify_backup(config_path, &dest);
|
||||
|
||||
Ok(BackupResult {
|
||||
files_copied: copied,
|
||||
total_size: total,
|
||||
dest_dir: dest,
|
||||
verified,
|
||||
})
|
||||
let backup_dir = crate::config::backups_config_dir();
|
||||
let (count, total, path) = create_tar_gz(config_path, &backup_dir, "", "ini")?;
|
||||
let verified = path.exists();
|
||||
Ok(BackupResult { files_copied: count, total_size: total, dest_path: path, verified })
|
||||
}
|
||||
|
||||
/// Restore .ini files from a backup into the Config\Windows folder.
|
||||
pub fn restore_ini_files(
|
||||
backup_dir: &Path,
|
||||
config_path: &Path,
|
||||
backup_root: &Path,
|
||||
) -> Result<usize> {
|
||||
// Pre-restore safety
|
||||
let ts = Local::now().format("%Y-%m-%d_%H%M%S");
|
||||
let pre_restore = backup_root.join(format!("ini_pre_restore_{ts}"));
|
||||
fs::create_dir_all(&pre_restore)?;
|
||||
|
||||
// Back up current .ini files
|
||||
if let Ok(entries) = fs::read_dir(config_path) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
if name.to_string_lossy().ends_with(".ini") {
|
||||
fs::copy(entry.path(), pre_restore.join(&name)).ok();
|
||||
}
|
||||
}
|
||||
pub fn restore_ini_files(archive_path: &Path, config_path: &Path) -> Result<usize> {
|
||||
// Pre-restore safety: back up current .ini files
|
||||
if let Err(_e) = backup_ini_files(config_path) {
|
||||
// pre-restore failure is non-fatal
|
||||
}
|
||||
|
||||
// Copy all files from backup → config_path
|
||||
let mut count = 0usize;
|
||||
for entry in fs::read_dir(backup_dir)? {
|
||||
let entry = entry?;
|
||||
fs::copy(entry.path(), config_path.join(entry.file_name()))?;
|
||||
count += 1;
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
extract_tar_gz(archive_path, config_path)
|
||||
}
|
||||
|
||||
/// Delete all .ini files from the Config\Windows folder.
|
||||
///
|
||||
/// **Guarded**: refuses to delete unless at least one `ini_backup_*` exists
|
||||
/// in the backup root.
|
||||
pub fn delete_ini_files(config_path: &Path, backup_root: &Path) -> Result<usize> {
|
||||
// Guard: check for existing backup
|
||||
let has_backup = backup_root.exists()
|
||||
&& fs::read_dir(backup_root)
|
||||
.map(|entries| {
|
||||
entries.flatten().any(|e| {
|
||||
let file_name = e.file_name();
|
||||
let name = file_name.to_string_lossy();
|
||||
name.starts_with("ini_backup_")
|
||||
&& e.path().is_dir()
|
||||
&& std::fs::read_dir(e.path()).is_ok_and(|mut d| {
|
||||
d.any(|f| f.ok().is_some_and(|f| {
|
||||
f.file_name().to_string_lossy().ends_with(".ini")
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if !has_backup {
|
||||
anyhow::bail!(
|
||||
"no .ini backup found — create a backup first via 'Manage Config > Backup'"
|
||||
);
|
||||
}
|
||||
|
||||
pub fn delete_ini_files(config_path: &Path) -> Result<usize> {
|
||||
let mut deleted = 0usize;
|
||||
for entry in fs::read_dir(config_path)? {
|
||||
let entry = entry?;
|
||||
@@ -268,35 +244,28 @@ pub fn delete_ini_files(config_path: &Path, backup_root: &Path) -> Result<usize>
|
||||
deleted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
/// List existing full backups in the backup root.
|
||||
pub fn list_full_backups(backup_root: &Path) -> Vec<PathBuf> {
|
||||
list_subdirs(backup_root, "notalterra_copy_")
|
||||
// ── listing ────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn list_full_backups() -> Vec<PathBuf> {
|
||||
list_tar_gz(&crate::config::backups_saves_dir())
|
||||
}
|
||||
|
||||
/// List existing .ini backups in the backup root.
|
||||
pub fn list_ini_backups(backup_root: &Path) -> Vec<PathBuf> {
|
||||
list_subdirs(backup_root, "ini_backup_")
|
||||
pub fn list_ini_backups() -> Vec<PathBuf> {
|
||||
list_tar_gz(&crate::config::backups_config_dir())
|
||||
}
|
||||
|
||||
/// List .bak files in a save folder, sorted by mtime descending.
|
||||
/// List .bak files in the save folder, sorted by mtime descending.
|
||||
pub fn list_bak_files(save_folder: &Path) -> Vec<PathBuf> {
|
||||
let mut files: Vec<PathBuf> = fs::read_dir(save_folder)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| {
|
||||
e.file_name()
|
||||
.to_string_lossy()
|
||||
.ends_with(".bak")
|
||||
})
|
||||
.filter(|e| e.file_name().to_string_lossy().ends_with(".bak"))
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
|
||||
// Sort by mtime descending
|
||||
files.sort_by(|a, b| {
|
||||
let ma = fs::metadata(a).ok();
|
||||
let mb = fs::metadata(b).ok();
|
||||
@@ -305,7 +274,6 @@ pub fn list_bak_files(save_folder: &Path) -> Vec<PathBuf> {
|
||||
_ => std::cmp::Ordering::Equal,
|
||||
}
|
||||
});
|
||||
|
||||
files
|
||||
}
|
||||
|
||||
@@ -323,72 +291,36 @@ pub struct BakFileSummary {
|
||||
}
|
||||
|
||||
/// List .bak files with parsed GVAS metadata.
|
||||
///
|
||||
/// Each file is read to extract `SlotName` and `DisplayName`. Files that
|
||||
/// fail to parse still appear — the metadata fields are simply empty.
|
||||
pub fn list_bak_files_with_meta(save_folder: &Path) -> Vec<BakFileSummary> {
|
||||
use chrono::TimeZone;
|
||||
|
||||
let mut files: Vec<BakFileSummary> = Vec::new();
|
||||
|
||||
let entries: Vec<_> = fs::read_dir(save_folder)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| {
|
||||
e.file_name()
|
||||
.to_string_lossy()
|
||||
.ends_with(".bak")
|
||||
})
|
||||
.filter(|e| e.file_name().to_string_lossy().ends_with(".bak"))
|
||||
.collect();
|
||||
|
||||
for entry in entries {
|
||||
let path = entry.path();
|
||||
let filename = entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
let meta = fs::metadata(&path).ok();
|
||||
let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
|
||||
let mtime = meta
|
||||
.as_ref()
|
||||
let mtime = meta.as_ref()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| {
|
||||
let secs = t
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()?
|
||||
.as_secs();
|
||||
Local
|
||||
.timestamp_opt(secs as i64, 0)
|
||||
.single()
|
||||
let secs = t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs();
|
||||
Local.timestamp_opt(secs as i64, 0).single()
|
||||
.map(|dt| dt.format("%Y-%b-%d %H:%M").to_string())
|
||||
});
|
||||
|
||||
// Use filename-derived slot for grouping (authoritative).
|
||||
// Internal SlotName is advisory — files may have been moved.
|
||||
let slot = derive_slot_from_filename(&filename).unwrap_or_else(|| "?".into());
|
||||
let meta = extract_metadata(&path).ok();
|
||||
let display_name = meta.as_ref().and_then(|m| m.display_name.clone());
|
||||
let is_online = meta.as_ref().map(|m| m.is_online).unwrap_or(false);
|
||||
let playtime_seconds = meta.as_ref().and_then(|m| m.playtime_seconds);
|
||||
|
||||
files.push(BakFileSummary {
|
||||
path,
|
||||
filename,
|
||||
slot,
|
||||
display_name,
|
||||
is_online,
|
||||
size,
|
||||
mtime,
|
||||
playtime_seconds,
|
||||
});
|
||||
files.push(BakFileSummary { path, filename, slot, display_name, is_online, size, mtime, playtime_seconds });
|
||||
}
|
||||
|
||||
// Sort by mtime descending, then by slot
|
||||
files.sort_by(|a, b| {
|
||||
a.slot
|
||||
.cmp(&b.slot)
|
||||
.then_with(|| b.mtime.cmp(&a.mtime))
|
||||
});
|
||||
|
||||
files.sort_by(|a, b| a.slot.cmp(&b.slot).then_with(|| b.mtime.cmp(&a.mtime)));
|
||||
files
|
||||
}
|
||||
|
||||
@@ -405,10 +337,7 @@ pub fn dedup_by_slot(files: Vec<BakFileSummary>) -> Vec<BakFileSummary> {
|
||||
}
|
||||
|
||||
/// Scan the save folder and return stats for the dashboard.
|
||||
pub fn folder_stats(
|
||||
save_folder: Option<&Path>,
|
||||
backup_root: &Path,
|
||||
) -> (usize, usize, bool) {
|
||||
pub fn folder_stats(save_folder: Option<&Path>) -> (usize, usize, bool) {
|
||||
let (live, bak) = if let Some(dir) = save_folder {
|
||||
if let Ok(entries) = fs::read_dir(dir) {
|
||||
let mut l = 0;
|
||||
@@ -430,127 +359,178 @@ pub fn folder_stats(
|
||||
(0, 0)
|
||||
};
|
||||
|
||||
let ini = backup_root.exists()
|
||||
&& fs::read_dir(backup_root)
|
||||
.map(|entries| {
|
||||
entries.flatten().any(|e| {
|
||||
let file_name = e.file_name();
|
||||
let name = file_name.to_string_lossy();
|
||||
name.starts_with("ini_backup_")
|
||||
&& e.path().is_dir()
|
||||
&& std::fs::read_dir(e.path()).is_ok_and(|mut d| {
|
||||
d.any(|f| f.ok().is_some_and(|f| {
|
||||
f.file_name().to_string_lossy().ends_with(".ini")
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
let ini_has_backup = crate::config::backups_config_dir().exists()
|
||||
&& fs::read_dir(crate::config::backups_config_dir())
|
||||
.map(|entries| entries.flatten().any(|e| {
|
||||
e.file_name().to_string_lossy().ends_with(".tar.gz")
|
||||
}))
|
||||
.unwrap_or(false);
|
||||
|
||||
(live, bak, ini)
|
||||
let _ = crate::config::backups_saves_dir(); // ensure dir exists
|
||||
(live, bak, ini_has_backup)
|
||||
}
|
||||
|
||||
// ── internal helpers ───────────────────────────────────────────────────────
|
||||
// ── migration ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Copy only files matching `savegame_*` prefix from `src` to `dest`.
|
||||
fn copy_save_files(
|
||||
src: &Path,
|
||||
dest: &Path,
|
||||
count: &mut usize,
|
||||
total_size: &mut u64,
|
||||
) -> Result<()> {
|
||||
fs::create_dir_all(dest)?;
|
||||
for entry in fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
let meta = entry.metadata()?;
|
||||
if meta.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if meta.is_file() && name_str.starts_with("savegame_") {
|
||||
let dest_path = dest.join(&name);
|
||||
fs::copy(entry.path(), &dest_path)?;
|
||||
*count += 1;
|
||||
*total_size += meta.len();
|
||||
}
|
||||
/// Migrate old `NotAlterra_Backups/` directory-tree backups into the new
|
||||
/// tar.gz format. Each timestamped directory becomes its own `.tar.gz`
|
||||
/// archive in `backups/saves/`. The old directory is not deleted.
|
||||
pub fn migrate_old_backups() -> Result<usize> {
|
||||
let old_root = crate::config::exe_dir().join("NotAlterra_Backups");
|
||||
migrate_backups_from(old_root)
|
||||
}
|
||||
|
||||
/// Migrate old directory-tree backups from a given root path.
|
||||
/// Separated from `migrate_old_backups()` so tests can use temp directories.
|
||||
fn migrate_backups_from(old_root: PathBuf) -> Result<usize> {
|
||||
if !old_root.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recursively copy a directory tree.
|
||||
fn copy_recursive(
|
||||
src: &Path,
|
||||
dest: &Path,
|
||||
count: &mut usize,
|
||||
total_size: &mut u64,
|
||||
) -> Result<()> {
|
||||
fs::create_dir_all(dest)?;
|
||||
|
||||
for entry in fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let src_path = entry.path();
|
||||
let name = entry.file_name();
|
||||
let dest_path = dest.join(&name);
|
||||
|
||||
let meta = entry.metadata()?;
|
||||
if meta.is_dir() {
|
||||
copy_recursive(&src_path, &dest_path, count, total_size)?;
|
||||
} else if meta.is_file() {
|
||||
fs::copy(&src_path, &dest_path)?;
|
||||
*count += 1;
|
||||
*total_size += meta.len();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verify that files in `src` match those in `dest` (by relative path + size).
|
||||
fn verify_backup(src: &Path, dest: &Path) -> bool {
|
||||
let Ok(src_entries) = fs::read_dir(src) else { return false };
|
||||
for entry in src_entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
if !name_str.starts_with("savegame_") {
|
||||
continue;
|
||||
}
|
||||
let expected = dest.join(&name);
|
||||
if !expected.exists() {
|
||||
return false;
|
||||
}
|
||||
if let (Ok(sm), Ok(dm)) = (entry.metadata(), fs::metadata(&expected)) {
|
||||
if sm.len() != dm.len() {
|
||||
return false;
|
||||
let mut migrated = 0usize;
|
||||
if let Ok(entries) = fs::read_dir(&old_root) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let dir_name = entry.file_name().to_string_lossy().to_string();
|
||||
if !dir_name.starts_with("notalterra_copy_") {
|
||||
continue;
|
||||
}
|
||||
// Check if this directory contains save files
|
||||
let has_saves = fs::read_dir(&path)
|
||||
.map(|e| e.flatten().any(|f| {
|
||||
let fname = f.file_name();
|
||||
let n = fname.to_string_lossy();
|
||||
n.starts_with("savegame_")
|
||||
}))
|
||||
.unwrap_or(false);
|
||||
if !has_saves {
|
||||
continue;
|
||||
}
|
||||
let backup_dir = crate::config::backups_saves_dir();
|
||||
match create_tar_gz(&path, &backup_dir, "savegame_", &format!("migrated_{dir_name}")) {
|
||||
Ok((_count, _size, archive_path)) if archive_path.exists() => {
|
||||
migrated += 1;
|
||||
}
|
||||
Ok(_) => {},
|
||||
Err(e) => {
|
||||
eprintln!("migration warning: failed to archive {:?}: {}", path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
Ok(migrated)
|
||||
}
|
||||
|
||||
/// List subdirectories whose names begin with a given prefix.
|
||||
fn list_subdirs(root: &Path, prefix: &str) -> Vec<PathBuf> {
|
||||
if !root.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut dirs: Vec<PathBuf> = fs::read_dir(root)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| {
|
||||
e.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with(prefix)
|
||||
&& e.path().is_dir()
|
||||
})
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
dirs.sort_by(|a, b| {
|
||||
let ma = fs::metadata(a).map(|m| m.modified().ok()).ok().flatten();
|
||||
let mb = fs::metadata(b).map(|m| m.modified().ok()).ok().flatten();
|
||||
match (ma, mb) {
|
||||
(Some(a), Some(b)) => b.cmp(&a),
|
||||
_ => std::cmp::Ordering::Equal,
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
/// Helper: create an old-style NotAlterra_Backups directory with save files.
|
||||
fn create_old_backup(root: &Path, dir_name: &str, save_names: &[&str]) -> PathBuf {
|
||||
let dir = root.join(dir_name);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
for (i, name) in save_names.iter().enumerate() {
|
||||
fs::write(dir.join(name), format!("content-{i}").as_bytes()).unwrap();
|
||||
}
|
||||
});
|
||||
dirs
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_old_backups_basic() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old_root = tmp.path().join("NotAlterra_Backups");
|
||||
fs::create_dir_all(&old_root).unwrap();
|
||||
create_old_backup(&old_root, "notalterra_copy_2026-01-01_120000", &["savegame_0.sav"]);
|
||||
create_old_backup(&old_root, "notalterra_copy_2026-01-02_120000", &["savegame_0.sav", "savegame_1.sav"]);
|
||||
|
||||
let count = migrate_backups_from(old_root.clone()).unwrap();
|
||||
assert_eq!(count, 2, "two old backups should be migrated");
|
||||
|
||||
// Verify archives exist in the shared backup directory
|
||||
let saves_dir = crate::config::backups_saves_dir();
|
||||
let archives: Vec<_> = fs::read_dir(&saves_dir).unwrap()
|
||||
.flatten()
|
||||
.filter(|e| e.file_name().to_string_lossy().contains("migrated_"))
|
||||
.collect();
|
||||
assert!(archives.len() >= 2, "at least 2 migrated archives should exist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_old_backups_empty_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old_root = tmp.path().join("NotAlterra_Backups");
|
||||
fs::create_dir_all(&old_root).unwrap();
|
||||
// Empty directory — nothing to migrate
|
||||
let count = migrate_backups_from(old_root.clone()).unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_old_backups_nonexistent_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old_root = tmp.path().join("NotAlterra_Backups");
|
||||
// Directory doesn't exist
|
||||
let count = migrate_backups_from(old_root.clone()).unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_old_backups_file_integrity() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old_root = tmp.path().join("NotAlterra_Backups");
|
||||
fs::create_dir_all(&old_root).unwrap();
|
||||
let _dir = create_old_backup(&old_root, "notalterra_copy_2026-01-01_120000", &["savegame_0.sav", "savegame_1.sav"]);
|
||||
|
||||
let count = migrate_backups_from(old_root.clone()).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
// Find the migrated archive
|
||||
let saves_dir = crate::config::backups_saves_dir();
|
||||
let archive: Option<PathBuf> = fs::read_dir(&saves_dir).unwrap()
|
||||
.flatten()
|
||||
.filter(|e| e.file_name().to_string_lossy().contains("migrated_"))
|
||||
.map(|e| e.path())
|
||||
.find(|_| true);
|
||||
assert!(archive.is_some(), "migrated archive should exist");
|
||||
|
||||
// Extract to a temp dir and verify content
|
||||
let extract_dir = tmp.path().join("extracted");
|
||||
fs::create_dir_all(&extract_dir).unwrap();
|
||||
let extracted = extract_tar_gz(&archive.unwrap(), &extract_dir).unwrap();
|
||||
assert_eq!(extracted, 2, "both save files should be restored");
|
||||
assert_eq!(fs::read_to_string(extract_dir.join("savegame_0.sav")).unwrap(), "content-0");
|
||||
assert_eq!(fs::read_to_string(extract_dir.join("savegame_1.sav")).unwrap(), "content-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_old_backups_skips_non_save_dirs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old_root = tmp.path().join("NotAlterra_Backups");
|
||||
fs::create_dir_all(&old_root).unwrap();
|
||||
create_old_backup(&old_root, "notalterra_copy_valid", &["savegame_0.sav"]);
|
||||
create_old_backup(&old_root, "notalterra_copy_empty", &[]); // no save files
|
||||
create_old_backup(&old_root, "unrelated_dir", &["savegame_0.sav"]); // wrong prefix
|
||||
fs::write(old_root.join("random_file.txt"), b"not a backup").unwrap();
|
||||
|
||||
let count = migrate_backups_from(old_root.clone()).unwrap();
|
||||
assert_eq!(count, 1, "only the dir with save files and correct prefix should be migrated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_old_backups_dedup_filename() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old_root = tmp.path().join("NotAlterra_Backups");
|
||||
fs::create_dir_all(&old_root).unwrap();
|
||||
create_old_backup(&old_root, "notalterra_copy_session1", &["savegame_0.sav"]);
|
||||
|
||||
// Migrate twice — second pass should not fail
|
||||
let c1 = migrate_backups_from(old_root.clone()).unwrap();
|
||||
assert_eq!(c1, 1, "first migration");
|
||||
let c2 = migrate_backups_from(old_root.clone()).unwrap();
|
||||
assert_eq!(c2, 1, "second migration (should not duplicate)");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user