//! 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`. use crate::gvas::{derive_slot_from_filename, extract_metadata}; use anyhow::{Context, Result}; use chrono::Local; use std::fs; use std::path::{Path, PathBuf}; // ── public operation types ───────────────────────────────────────────────── /// Result of a backup operation. #[derive(Debug, Clone)] pub struct BackupResult { pub files_copied: usize, pub total_size: u64, pub dest_dir: PathBuf, pub verified: bool, } /// Result of a recovery operation. #[derive(Debug, Clone)] pub struct RecoveryResult { pub source: String, pub target: String, pub old_saved_as: Option, } // ── .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 `.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 { 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() ); } // 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() ) })?; Ok(RecoveryResult { source: bak_filename.to_string(), target: target_name, old_saved_as, }) } // ── full backup ──────────────────────────────────────────────────────────── /// Create a full backup of the save folder to `NotAlterra_Backups/notalterra_copy_`. /// 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 { let ts = Local::now().format("%Y-%m-%d_%H%M%S"); let dest = backup_root.join(format!("notalterra_copy_{ts}")); fs::create_dir_all(&dest) .with_context(|| format!("cannot create backup dir {}", dest.display()))?; let mut copied = 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); } // Verify let verified = verify_backup(save_folder, &dest); Ok(BackupResult { files_copied: copied, total_size: total, dest_dir: dest, verified, }) } /// 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 { // 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 let mut count = 0usize; let mut _sz = 0u64; copy_save_files(backup_dir, save_folder, &mut count, &mut _sz)?; Ok(count) } // ── .ini management ──────────────────────────────────────────────────────── /// Back up .ini files from the Config\Windows folder. pub fn backup_ini_files(config_path: &Path, backup_root: &Path) -> Result { let ini_files: Vec = 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") }) .map(|e| e.path()) .collect(); if ini_files.is_empty() { 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, }) } /// 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 { // 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(); } } } // 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) } /// 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 { // 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'" ); } let mut deleted = 0usize; for entry in fs::read_dir(config_path)? { let entry = entry?; if entry.file_name().to_string_lossy().ends_with(".ini") { fs::remove_file(entry.path())?; deleted += 1; } } Ok(deleted) } /// List existing full backups in the backup root. pub fn list_full_backups(backup_root: &Path) -> Vec { list_subdirs(backup_root, "notalterra_copy_") } /// List existing .ini backups in the backup root. pub fn list_ini_backups(backup_root: &Path) -> Vec { list_subdirs(backup_root, "ini_backup_") } /// List .bak files in a save folder, sorted by mtime descending. pub fn list_bak_files(save_folder: &Path) -> Vec { let mut files: Vec = fs::read_dir(save_folder) .into_iter() .flatten() .flatten() .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(); match (ma.and_then(|m| m.modified().ok()), mb.and_then(|m| m.modified().ok())) { (Some(a), Some(b)) => b.cmp(&a), _ => std::cmp::Ordering::Equal, } }); files } /// Enriched .bak file entry with GVAS metadata for the picker UI. #[derive(Debug, Clone)] pub struct BakFileSummary { pub path: PathBuf, pub filename: String, pub slot: String, pub display_name: Option, pub is_online: bool, pub size: u64, pub mtime: Option, pub playtime_seconds: Option, } /// 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 { use chrono::TimeZone; let mut files: Vec = 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") }) .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() .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() .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, }); } // 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 } /// Keep only the most recent .bak per slot, discarding older versioned backups. pub fn dedup_by_slot(files: Vec) -> Vec { let mut seen: std::collections::HashSet = std::collections::HashSet::new(); let mut out = Vec::new(); for f in files { if seen.insert(f.slot.clone()) { out.push(f); } } out } /// Scan the save folder and return stats for the dashboard. pub fn folder_stats( save_folder: Option<&Path>, backup_root: &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; let mut b = 0; for e in entries.flatten() { let name = e.file_name(); let name_str = name.to_string_lossy(); if name_str.starts_with("savegame_") && name_str.ends_with(".sav") { l += 1; } else if name_str.starts_with("savegame_") && name_str.ends_with(".bak") { b += 1; } } (l, b) } else { (0, 0) } } else { (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") })) }) }) }) .unwrap_or(false); (live, bak, ini) } // ── internal helpers ─────────────────────────────────────────────────────── /// 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(); } } 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; } } } true } /// List subdirectories whose names begin with a given prefix. fn list_subdirs(root: &Path, prefix: &str) -> Vec { if !root.exists() { return Vec::new(); } let mut dirs: Vec = 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, } }); dirs }