1use crate::gvas::{derive_slot_from_filename, extract_metadata};
8use anyhow::{Context, Result};
9use chrono::Local;
10use flate2::write::GzEncoder;
11use flate2::Compression;
12use std::fs;
13
14use std::path::{Path, PathBuf};
15use tar::Archive;
16
17#[derive(Debug, Clone)]
21pub struct BackupResult {
22 pub files_copied: usize,
23 pub total_size: u64,
24 pub dest_path: PathBuf,
25 pub verified: bool,
26}
27
28#[derive(Debug, Clone)]
30pub struct RecoveryResult {
31 pub source: String,
32 pub target: String,
33 pub old_saved_as: Option<String>,
34}
35
36pub fn recover_bak_to_sav(save_folder: &Path, bak_filename: &str) -> Result<RecoveryResult> {
39 let bak_path = save_folder.join(bak_filename);
40 if !bak_path.exists() {
41 anyhow::bail!("backup file not found: {}", bak_path.display());
42 }
43 let meta =
44 fs::metadata(&bak_path).with_context(|| format!("cannot read {}", bak_path.display()))?;
45 if meta.len() < 1024 {
46 anyhow::bail!(
47 "backup file too small ({} bytes) — aborting restore",
48 meta.len()
49 );
50 }
51 let slot = derive_slot_from_filename(bak_filename)
52 .ok_or_else(|| anyhow::anyhow!("cannot derive slot from filename: {bak_filename}"))?;
53 let target_name = format!("{slot}.sav");
54 let target_path = save_folder.join(&target_name);
55 let mut old_saved_as = None;
56 if target_path.exists() {
57 let old_path = save_folder.join(format!("{target_name}.old"));
58 fs::rename(&target_path, &old_path).with_context(|| {
59 format!(
60 "cannot rename {} → {}",
61 target_path.display(),
62 old_path.display()
63 )
64 })?;
65 old_saved_as = Some(format!("{target_name}.old"));
66 }
67 fs::copy(&bak_path, &target_path).with_context(|| {
68 format!(
69 "cannot copy {} → {}",
70 bak_path.display(),
71 target_path.display()
72 )
73 })?;
74 Ok(RecoveryResult {
75 source: bak_filename.to_string(),
76 target: target_name,
77 old_saved_as,
78 })
79}
80
81fn create_tar_gz(
86 src: &Path,
87 dest_dir: &Path,
88 prefix: &str,
89 name: &str,
90) -> Result<(usize, u64, PathBuf)> {
91 let ts = Local::now().format("%Y-%m-%d_%H%M%S_%3f");
92 let archive_name = format!("{name}_{ts}.tar.gz");
93 let archive_path = dest_dir.join(&archive_name);
94
95 let file = fs::File::create(&archive_path)
96 .with_context(|| format!("cannot create {}", archive_path.display()))?;
97 let enc = GzEncoder::new(file, Compression::default());
98 let mut tar_builder = tar::Builder::new(enc);
99 let mut count = 0usize;
100 let mut total = 0u64;
101
102 let entries: Vec<_> = fs::read_dir(src)?
103 .flatten()
104 .filter(|e| {
105 let fname = e.file_name();
106 let name_lossy = fname.to_string_lossy();
107 name_lossy.starts_with(prefix) || name_lossy.starts_with("savegame_")
108 })
109 .collect();
110
111 let mut manifest = String::new();
113 for entry in &entries {
114 let name = entry.file_name().to_string_lossy().to_string();
115 let meta = entry.metadata().ok();
116 let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
117 manifest.push_str(&format!("{size:>12} {name}\n"));
118 }
119 let manifest_bytes = manifest.into_bytes();
120 let mut header = tar::Header::new_gnu();
121 header.set_path("MANIFEST")?;
122 header.set_size(manifest_bytes.len() as u64);
123 header.set_mode(0o644);
124 header.set_cksum();
125 tar_builder.append(&header, &manifest_bytes[..])?;
126 count += 1;
127
128 for entry in &entries {
129 let src_path = entry.path();
130 let name = entry.file_name().to_string_lossy().to_string();
131 let meta = entry.metadata()?;
132 let size = meta.len();
133 let data = fs::read(&src_path)
134 .with_context(|| format!("failed to read {}", src_path.display()))?;
135 let mut header = tar::Header::new_gnu();
136 header
137 .set_path(&name)
138 .with_context(|| format!("failed to set path '{name}' in tar header"))?;
139 header.set_size(data.len() as u64);
140 header.set_mode(0o644); if let Ok(src_meta) = fs::metadata(&src_path) {
144 if let Ok(mtime) = src_meta.modified() {
145 if let Ok(dur) = mtime.duration_since(std::time::UNIX_EPOCH) {
146 header.set_mtime(dur.as_secs());
147 }
148 }
149 }
150 header.set_cksum();
151 tar_builder
152 .append(&header, &data[..])
153 .with_context(|| format!("failed to append '{name}' to tar archive"))?;
154 count += 1;
155 total += size;
156 }
157
158 let encoder = tar_builder.into_inner()?;
159 encoder.finish()?;
160
161 Ok((count, total, archive_path))
162}
163
164fn extract_tar_gz(archive_path: &Path, dest: &Path) -> Result<usize> {
168 let file = fs::File::open(archive_path)
169 .with_context(|| format!("cannot open {}", archive_path.display()))?;
170 let decoder = flate2::read::GzDecoder::new(file);
171 let mut archive = Archive::new(decoder);
172 let mut count = 0usize;
173
174 for entry in archive.entries()? {
175 let mut entry = entry?;
176 let path = entry.path()?.to_string_lossy().to_string();
177 if path == "MANIFEST" {
178 continue;
179 }
180 let dest_path = dest.join(&path);
181 entry.unpack(&dest_path)?;
182 count += 1;
183 }
184
185 Ok(count)
186}
187
188pub fn check_tar_gz_integrity(path: &Path) -> bool {
191 let meta = match std::fs::metadata(path) {
192 Ok(m) => m,
193 Err(_) => return false,
194 };
195 if meta.len() < 20 {
196 return false; }
198 let mut buf = [0u8; 2];
199 use std::io::Read;
200 if let Ok(mut f) = std::fs::File::open(path) {
201 if f.read_exact(&mut buf).is_ok() {
202 return buf == [0x1f, 0x8b]; }
204 }
205 false
206}
207
208fn list_tar_gz(dir: &Path) -> Vec<PathBuf> {
210 if !dir.exists() {
211 return Vec::new();
212 }
213 let mut files: Vec<PathBuf> = fs::read_dir(dir)
214 .into_iter()
215 .flatten()
216 .flatten()
217 .filter(|e| e.file_name().to_string_lossy().ends_with(".tar.gz"))
218 .map(|e| e.path())
219 .collect();
220 files.sort_by(|a, b| {
221 let ma = fs::metadata(a).ok().and_then(|m| m.modified().ok());
222 let mb = fs::metadata(b).ok().and_then(|m| m.modified().ok());
223 match (ma, mb) {
224 (Some(a), Some(b)) => b.cmp(&a),
225 _ => std::cmp::Ordering::Equal,
226 }
227 });
228 files
229}
230
231pub fn create_full_backup(save_folder: &Path) -> Result<BackupResult> {
234 let backup_dir = crate::config::backups_saves_dir();
235 let (count, total, path) = create_tar_gz(save_folder, &backup_dir, "savegame_", "snapshot")?;
236 let verified = path.exists();
237 Ok(BackupResult {
238 files_copied: count,
239 total_size: total,
240 dest_path: path,
241 verified,
242 })
243}
244
245pub fn restore_full_backup(archive_path: &Path, save_folder: &Path) -> Result<usize> {
246 let ts = Local::now().format("%Y-%m-%d_%H%M%S_%3f");
248 let pre_restore = crate::config::backups_saves_dir().join(format!("pre_restore_{ts}.tar.gz"));
249 if let Err(_e) = create_tar_gz(
250 save_folder,
251 &crate::config::backups_saves_dir(),
252 "savegame_",
253 "pre_restore",
254 ) {
255 }
257 let _ = pre_restore;
258
259 extract_tar_gz(archive_path, save_folder)
261}
262
263pub fn backup_ini_files(config_path: &Path) -> Result<BackupResult> {
266 let ini_files: Vec<PathBuf> = fs::read_dir(config_path)
267 .with_context(|| format!("cannot read {}", config_path.display()))?
268 .flatten()
269 .filter(|e| e.file_name().to_string_lossy().ends_with(".ini"))
270 .map(|e| e.path())
271 .collect();
272
273 if ini_files.is_empty() {
274 anyhow::bail!("no .ini files found in {}", config_path.display());
275 }
276
277 let backup_dir = crate::config::backups_config_dir();
278 let (count, total, path) = create_tar_gz(config_path, &backup_dir, "", "ini")?;
279 let verified = path.exists();
280 Ok(BackupResult {
281 files_copied: count,
282 total_size: total,
283 dest_path: path,
284 verified,
285 })
286}
287
288pub fn restore_ini_files(archive_path: &Path, config_path: &Path) -> Result<usize> {
289 if let Err(_e) = backup_ini_files(config_path) {
291 }
293 extract_tar_gz(archive_path, config_path)
294}
295
296pub fn delete_ini_files(config_path: &Path) -> Result<usize> {
297 let mut deleted = 0usize;
298 for entry in fs::read_dir(config_path)? {
299 let entry = entry?;
300 if entry.file_name().to_string_lossy().ends_with(".ini") {
301 fs::remove_file(entry.path())?;
302 deleted += 1;
303 }
304 }
305 Ok(deleted)
306}
307
308pub fn list_full_backups() -> Vec<PathBuf> {
311 list_tar_gz(&crate::config::backups_saves_dir())
312}
313
314pub fn list_ini_backups() -> Vec<PathBuf> {
315 list_tar_gz(&crate::config::backups_config_dir())
316}
317
318pub fn list_bak_files(save_folder: &Path) -> Vec<PathBuf> {
320 let mut files: Vec<PathBuf> = fs::read_dir(save_folder)
321 .into_iter()
322 .flatten()
323 .flatten()
324 .filter(|e| e.file_name().to_string_lossy().ends_with(".bak"))
325 .map(|e| e.path())
326 .collect();
327 files.sort_by(|a, b| {
328 let ma = fs::metadata(a).ok();
329 let mb = fs::metadata(b).ok();
330 match (
331 ma.and_then(|m| m.modified().ok()),
332 mb.and_then(|m| m.modified().ok()),
333 ) {
334 (Some(a), Some(b)) => b.cmp(&a),
335 _ => std::cmp::Ordering::Equal,
336 }
337 });
338 files
339}
340
341#[derive(Debug, Clone)]
343pub struct BakFileSummary {
344 pub path: PathBuf,
345 pub filename: String,
346 pub slot: String,
347 pub display_name: Option<String>,
348 pub is_online: bool,
349 pub size: u64,
350 pub mtime: Option<String>,
351 pub playtime_seconds: Option<f64>,
352}
353
354pub fn list_bak_files_with_meta(save_folder: &Path) -> Vec<BakFileSummary> {
356 use chrono::TimeZone;
357 let mut files: Vec<BakFileSummary> = Vec::new();
358 let entries: Vec<_> = fs::read_dir(save_folder)
359 .into_iter()
360 .flatten()
361 .flatten()
362 .filter(|e| e.file_name().to_string_lossy().ends_with(".bak"))
363 .collect();
364
365 for entry in entries {
366 let path = entry.path();
367 let filename = entry.file_name().to_string_lossy().to_string();
368 let meta = fs::metadata(&path).ok();
369 let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
370 let mtime = meta.as_ref().and_then(|m| {
371 let t = m.modified().or_else(|_| m.created()).ok()?;
373 let secs = t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs();
374 if secs == 0 { return None; }
376 Local
377 .timestamp_opt(secs as i64, 0)
378 .single()
379 .map(|dt| dt.format("%Y-%b-%d %H:%M").to_string())
380 });
381 let slot = derive_slot_from_filename(&filename).unwrap_or_else(|| "?".into());
382 let meta = extract_metadata(&path).ok();
383 let display_name = meta.as_ref().and_then(|m| m.display_name.clone());
384 let is_online = meta.as_ref().map(|m| m.is_online).unwrap_or(false);
385 let playtime_seconds = meta.as_ref().and_then(|m| m.playtime_seconds);
386 files.push(BakFileSummary {
387 path,
388 filename,
389 slot,
390 display_name,
391 is_online,
392 size,
393 mtime,
394 playtime_seconds,
395 });
396 }
397 files.sort_by(|a, b| a.slot.cmp(&b.slot).then_with(|| b.mtime.cmp(&a.mtime)));
398 files
399}
400
401pub fn dedup_by_slot(files: Vec<BakFileSummary>) -> Vec<BakFileSummary> {
403 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
404 let mut out = Vec::new();
405 for f in files {
406 if seen.insert(f.slot.clone()) {
407 out.push(f);
408 }
409 }
410 out
411}
412
413pub fn folder_stats(save_folder: Option<&Path>) -> (usize, usize, bool) {
415 let (live, bak) = if let Some(dir) = save_folder {
416 if let Ok(entries) = fs::read_dir(dir) {
417 let mut l = 0;
418 let mut b = 0;
419 for e in entries.flatten() {
420 let name = e.file_name();
421 let name_str = name.to_string_lossy();
422 if name_str.starts_with("savegame_") && name_str.ends_with(".sav") {
423 l += 1;
424 } else if name_str.starts_with("savegame_") && name_str.ends_with(".bak") {
425 b += 1;
426 }
427 }
428 (l, b)
429 } else {
430 (0, 0)
431 }
432 } else {
433 (0, 0)
434 };
435
436 let ini_has_backup = crate::config::backups_config_dir().exists()
437 && fs::read_dir(crate::config::backups_config_dir())
438 .map(|entries| {
439 entries
440 .flatten()
441 .any(|e| e.file_name().to_string_lossy().ends_with(".tar.gz"))
442 })
443 .unwrap_or(false);
444
445 let _ = crate::config::backups_saves_dir(); (live, bak, ini_has_backup)
447}
448
449pub fn migrate_old_backups() -> Result<usize> {
455 let old_root = crate::config::exe_dir().join("NotAlterra_Backups");
456 migrate_backups_from(old_root)
457}
458
459fn migrate_backups_from(old_root: PathBuf) -> Result<usize> {
462 if !old_root.exists() {
463 return Ok(0);
464 }
465
466 let mut migrated = 0usize;
467 if let Ok(entries) = fs::read_dir(&old_root) {
468 for entry in entries.flatten() {
469 let path = entry.path();
470 if !path.is_dir() {
471 continue;
472 }
473 let dir_name = entry.file_name().to_string_lossy().to_string();
474
475 if dir_name.starts_with("notalterra_copy_") {
476 let has_saves = fs::read_dir(&path)
478 .map(|e| {
479 e.flatten()
480 .any(|f| f.file_name().to_string_lossy().starts_with("savegame_"))
481 })
482 .unwrap_or(false);
483 if !has_saves {
484 continue;
485 }
486 let backup_dir = crate::config::backups_saves_dir();
487 match create_tar_gz(
488 &path,
489 &backup_dir,
490 "savegame_",
491 &format!("migrated_{dir_name}"),
492 ) {
493 Ok((_count, _size, archive_path)) if archive_path.exists() => {
494 migrated += 1;
495 }
496 Ok(_) => {}
497 Err(e) => {
498 eprintln!("migration warning: failed to archive {:?}: {}", path, e);
499 }
500 }
501 } else if dir_name.starts_with("config_") {
502 let backup_dir = crate::config::backups_config_dir();
504 match create_tar_gz(&path, &backup_dir, "", &format!("migrated_{dir_name}")) {
505 Ok((_count, _size, archive_path)) if archive_path.exists() => {
506 migrated += 1;
507 }
508 Ok(_) => {}
509 Err(e) => {
510 eprintln!("migration warning: failed to archive {:?}: {}", path, e);
511 }
512 }
513 }
514 }
515 }
516 Ok(migrated)
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522 use std::fs;
523
524 fn create_old_backup(root: &Path, dir_name: &str, save_names: &[&str]) -> PathBuf {
526 let dir = root.join(dir_name);
527 fs::create_dir_all(&dir).unwrap();
528 for (i, name) in save_names.iter().enumerate() {
529 fs::write(dir.join(name), format!("content-{i}").as_bytes()).unwrap();
530 }
531 dir
532 }
533
534 #[test]
535 fn test_migrate_old_backups_basic() {
536 let tmp = tempfile::tempdir().unwrap();
537 let old_root = tmp.path().join("NotAlterra_Backups");
538 fs::create_dir_all(&old_root).unwrap();
539 create_old_backup(
540 &old_root,
541 "notalterra_copy_2025-01-01_120000",
542 &["savegame_0.sav"],
543 );
544 create_old_backup(
545 &old_root,
546 "notalterra_copy_2025-01-02_120000",
547 &["savegame_0.sav", "savegame_1.sav"],
548 );
549
550 let count = migrate_backups_from(old_root.clone()).unwrap();
551 assert_eq!(count, 2, "two old backups should be migrated");
552
553 let saves_dir = crate::config::backups_saves_dir();
555 let archives: Vec<_> = fs::read_dir(&saves_dir)
556 .unwrap()
557 .flatten()
558 .filter(|e| e.file_name().to_string_lossy().contains("migrated_"))
559 .collect();
560 assert!(
561 archives.len() >= 2,
562 "at least 2 migrated archives should exist"
563 );
564 }
565
566 #[test]
567 fn test_migrate_old_backups_empty_dir() {
568 let tmp = tempfile::tempdir().unwrap();
569 let old_root = tmp.path().join("NotAlterra_Backups");
570 fs::create_dir_all(&old_root).unwrap();
571 let count = migrate_backups_from(old_root.clone()).unwrap();
573 assert_eq!(count, 0);
574 }
575
576 #[test]
577 fn test_migrate_old_backups_nonexistent_dir() {
578 let tmp = tempfile::tempdir().unwrap();
579 let old_root = tmp.path().join("NotAlterra_Backups");
580 let count = migrate_backups_from(old_root.clone()).unwrap();
582 assert_eq!(count, 0);
583 }
584
585 #[test]
586 fn test_migrate_old_backups_file_integrity() {
587 let tmp = tempfile::tempdir().unwrap();
588 let old_root = tmp.path().join("NotAlterra_Backups");
589 fs::create_dir_all(&old_root).unwrap();
590 let _dir = create_old_backup(
591 &old_root,
592 "notalterra_copy_2026-01-01_120000",
593 &["savegame_0.sav", "savegame_1.sav"],
594 );
595
596 let count = migrate_backups_from(old_root.clone()).unwrap();
597 assert_eq!(count, 1);
598
599 let saves_dir = crate::config::backups_saves_dir();
601 let archive: Option<PathBuf> = fs::read_dir(&saves_dir)
602 .unwrap()
603 .flatten()
604 .filter(|e| {
605 e.file_name()
606 .to_string_lossy()
607 .contains("migrated_notalterra_copy_2026-01-01")
608 })
609 .map(|e| e.path())
610 .find(|_| true);
611 assert!(
612 archive.is_some(),
613 "migrated archive should exist for 2026-01-01"
614 );
615
616 let extract_dir = tmp.path().join("extracted");
618 fs::create_dir_all(&extract_dir).unwrap();
619 let extracted = extract_tar_gz(&archive.unwrap(), &extract_dir).unwrap();
620 assert_eq!(extracted, 2, "both save files should be restored");
621 assert_eq!(
622 fs::read_to_string(extract_dir.join("savegame_0.sav")).unwrap(),
623 "content-0"
624 );
625 assert_eq!(
626 fs::read_to_string(extract_dir.join("savegame_1.sav")).unwrap(),
627 "content-1"
628 );
629 }
630
631 #[test]
632 fn test_migrate_old_backups_skips_non_save_dirs() {
633 let tmp = tempfile::tempdir().unwrap();
634 let old_root = tmp.path().join("NotAlterra_Backups");
635 fs::create_dir_all(&old_root).unwrap();
636 create_old_backup(&old_root, "notalterra_copy_valid", &["savegame_0.sav"]);
637 create_old_backup(&old_root, "notalterra_copy_empty", &[]); create_old_backup(&old_root, "unrelated_dir", &["savegame_0.sav"]); fs::write(old_root.join("random_file.txt"), b"not a backup").unwrap();
640
641 let count = migrate_backups_from(old_root.clone()).unwrap();
642 assert_eq!(
643 count, 1,
644 "only the dir with save files and correct prefix should be migrated"
645 );
646 }
647
648 #[test]
649 fn test_migrate_old_backups_dedup_filename() {
650 let tmp = tempfile::tempdir().unwrap();
651 let old_root = tmp.path().join("NotAlterra_Backups");
652 fs::create_dir_all(&old_root).unwrap();
653 create_old_backup(&old_root, "notalterra_copy_session1", &["savegame_0.sav"]);
654
655 let c1 = migrate_backups_from(old_root.clone()).unwrap();
657 assert_eq!(c1, 1, "first migration");
658 let c2 = migrate_backups_from(old_root.clone()).unwrap();
659 assert_eq!(c2, 1, "second migration (should not duplicate)");
660 }
661
662 #[test]
665 fn test_integrity_check_valid_gzip() {
666 let dir = tempfile::tempdir().unwrap();
667 let path = dir.path().join("test.tar.gz");
668 let valid_gzip = [
670 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x03, 0x00, 0x00, 0x00,
671 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
672 ];
673 fs::write(&path, &valid_gzip).unwrap();
674 assert!(check_tar_gz_integrity(&path));
675 }
676
677 #[test]
678 fn test_integrity_check_empty_file() {
679 let dir = tempfile::tempdir().unwrap();
680 let path = dir.path().join("empty.tar.gz");
681 fs::write(&path, b"").unwrap();
682 assert!(!check_tar_gz_integrity(&path));
683 }
684
685 #[test]
686 fn test_integrity_check_non_gzip() {
687 let dir = tempfile::tempdir().unwrap();
688 let path = dir.path().join("not_gzip.tar.gz");
689 fs::write(&path, b"this is not a gzip file").unwrap();
690 assert!(!check_tar_gz_integrity(&path));
691 }
692
693 #[test]
694 fn test_integrity_check_too_small() {
695 let dir = tempfile::tempdir().unwrap();
696 let path = dir.path().join("tiny.tar.gz");
697 fs::write(&path, &[0x1f, 0x8b, 0x08]).unwrap();
699 assert!(!check_tar_gz_integrity(&path));
700 }
701
702 #[test]
703 fn test_integrity_check_nonexistent_file() {
704 let path = std::path::Path::new("/nonexistent/path.tar.gz");
705 assert!(!check_tar_gz_integrity(&path));
706 }
707}