1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4
5use crate::error::AftError;
6
7const MAX_UNDO_DEPTH: usize = 20;
8
9const SCHEMA_VERSION: u32 = 2;
14
15#[derive(Debug, Clone)]
17pub struct BackupEntry {
18 pub backup_id: String,
19 pub content: String,
20 pub timestamp: u64,
21 pub description: String,
22}
23
24#[derive(Debug)]
43pub struct BackupStore {
44 entries: HashMap<String, HashMap<PathBuf, Vec<BackupEntry>>>,
46 disk_index: HashMap<String, HashMap<PathBuf, DiskMeta>>,
48 session_meta: HashMap<String, SessionMeta>,
50 counter: AtomicU64,
51 storage_dir: Option<PathBuf>,
52}
53
54#[derive(Debug, Clone)]
55struct DiskMeta {
56 dir: PathBuf,
57 count: usize,
58}
59
60#[derive(Debug, Clone, Default)]
61struct SessionMeta {
62 last_accessed: u64,
65}
66
67impl BackupStore {
68 pub fn new() -> Self {
69 BackupStore {
70 entries: HashMap::new(),
71 disk_index: HashMap::new(),
72 session_meta: HashMap::new(),
73 counter: AtomicU64::new(0),
74 storage_dir: None,
75 }
76 }
77
78 pub fn set_storage_dir(&mut self, dir: PathBuf) {
83 self.storage_dir = Some(dir);
84 self.migrate_legacy_layout_if_needed();
85 self.load_disk_index();
86 }
87
88 pub fn snapshot(
90 &mut self,
91 session: &str,
92 path: &Path,
93 description: &str,
94 ) -> Result<String, AftError> {
95 let content = std::fs::read_to_string(path).map_err(|_| AftError::FileNotFound {
96 path: path.display().to_string(),
97 })?;
98
99 let key = canonicalize_key(path);
100 let id = self.next_id();
101 let entry = BackupEntry {
102 backup_id: id.clone(),
103 content,
104 timestamp: current_timestamp(),
105 description: description.to_string(),
106 };
107
108 let session_entries = self.entries.entry(session.to_string()).or_default();
109 let stack = session_entries.entry(key.clone()).or_default();
110 if stack.len() >= MAX_UNDO_DEPTH {
111 stack.remove(0);
112 }
113 stack.push(entry);
114
115 let stack_clone = stack.clone();
117 self.write_snapshot_to_disk(session, &key, &stack_clone);
118 self.touch_session(session);
119
120 Ok(id)
121 }
122
123 pub fn restore_latest(
126 &mut self,
127 session: &str,
128 path: &Path,
129 ) -> Result<(BackupEntry, Option<String>), AftError> {
130 let key = canonicalize_key(path);
131
132 let in_memory = self
134 .entries
135 .get(session)
136 .and_then(|s| s.get(&key))
137 .map_or(false, |s| !s.is_empty());
138 if in_memory {
139 let result = self.do_restore(session, &key, path);
140 if result.is_ok() {
141 self.touch_session(session);
142 }
143 return result;
144 }
145
146 if self.load_from_disk_if_needed(session, &key) {
148 let warning = self.check_external_modification(session, &key, path);
150 let (entry, _) = self.do_restore(session, &key, path)?;
151 self.touch_session(session);
152 return Ok((entry, warning));
153 }
154
155 Err(AftError::NoUndoHistory {
156 path: path.display().to_string(),
157 })
158 }
159
160 pub fn history(&self, session: &str, path: &Path) -> Vec<BackupEntry> {
162 let key = canonicalize_key(path);
163 self.entries
164 .get(session)
165 .and_then(|s| s.get(&key))
166 .cloned()
167 .unwrap_or_default()
168 }
169
170 pub fn disk_history_count(&self, session: &str, path: &Path) -> usize {
172 let key = canonicalize_key(path);
173 self.disk_index
174 .get(session)
175 .and_then(|s| s.get(&key))
176 .map(|m| m.count)
177 .unwrap_or(0)
178 }
179
180 pub fn tracked_files(&self, session: &str) -> Vec<PathBuf> {
183 let mut files: std::collections::HashSet<PathBuf> = self
184 .entries
185 .get(session)
186 .map(|s| s.keys().cloned().collect())
187 .unwrap_or_default();
188 if let Some(disk) = self.disk_index.get(session) {
189 for key in disk.keys() {
190 files.insert(key.clone());
191 }
192 }
193 files.into_iter().collect()
194 }
195
196 pub fn sessions_with_backups(&self) -> Vec<String> {
199 let mut sessions: std::collections::HashSet<String> =
200 self.entries.keys().cloned().collect();
201 for s in self.disk_index.keys() {
202 sessions.insert(s.clone());
203 }
204 sessions.into_iter().collect()
205 }
206
207 pub fn total_disk_bytes(&self) -> u64 {
210 let mut total = 0u64;
211 for session_dirs in self.disk_index.values() {
212 for meta in session_dirs.values() {
213 if let Ok(read_dir) = std::fs::read_dir(&meta.dir) {
214 for entry in read_dir.flatten() {
215 if let Ok(m) = entry.metadata() {
216 if m.is_file() {
217 total += m.len();
218 }
219 }
220 }
221 }
222 }
223 }
224 total
225 }
226
227 fn next_id(&self) -> String {
228 let n = self.counter.fetch_add(1, Ordering::Relaxed);
229 format!("backup-{}", n)
230 }
231
232 fn touch_session(&mut self, session: &str) {
233 self.session_meta
234 .entry(session.to_string())
235 .or_default()
236 .last_accessed = current_timestamp();
237 }
238
239 fn do_restore(
242 &mut self,
243 session: &str,
244 key: &Path,
245 path: &Path,
246 ) -> Result<(BackupEntry, Option<String>), AftError> {
247 let session_entries =
248 self.entries
249 .get_mut(session)
250 .ok_or_else(|| AftError::NoUndoHistory {
251 path: path.display().to_string(),
252 })?;
253 let stack = session_entries
254 .get_mut(key)
255 .ok_or_else(|| AftError::NoUndoHistory {
256 path: path.display().to_string(),
257 })?;
258
259 let entry = stack
260 .last()
261 .cloned()
262 .ok_or_else(|| AftError::NoUndoHistory {
263 path: path.display().to_string(),
264 })?;
265
266 std::fs::write(path, &entry.content).map_err(|e| AftError::IoError {
267 path: path.display().to_string(),
268 message: e.to_string(),
269 })?;
270
271 stack.pop();
272 if stack.is_empty() {
273 session_entries.remove(key);
274 if session_entries.is_empty() {
276 self.entries.remove(session);
277 }
278 self.remove_disk_backups(session, key);
279 } else {
280 let stack_clone = self
281 .entries
282 .get(session)
283 .and_then(|s| s.get(key))
284 .cloned()
285 .unwrap_or_default();
286 self.write_snapshot_to_disk(session, key, &stack_clone);
287 }
288
289 Ok((entry, None))
290 }
291
292 fn check_external_modification(
293 &self,
294 session: &str,
295 key: &Path,
296 path: &Path,
297 ) -> Option<String> {
298 if let (Some(stack), Ok(current)) = (
299 self.entries.get(session).and_then(|s| s.get(key)),
300 std::fs::read_to_string(path),
301 ) {
302 if let Some(latest) = stack.last() {
303 if latest.content != current {
304 return Some("file was modified externally since last backup".to_string());
305 }
306 }
307 }
308 None
309 }
310
311 fn backups_dir(&self) -> Option<PathBuf> {
314 self.storage_dir.as_ref().map(|d| d.join("backups"))
315 }
316
317 fn session_dir(&self, session: &str) -> Option<PathBuf> {
318 self.backups_dir()
319 .map(|d| d.join(Self::session_hash(session)))
320 }
321
322 fn session_hash(session: &str) -> String {
323 use std::hash::{Hash, Hasher};
324 let mut hasher = std::collections::hash_map::DefaultHasher::new();
325 session.hash(&mut hasher);
326 format!("{:016x}", hasher.finish())
327 }
328
329 fn path_hash(key: &Path) -> String {
330 use std::hash::{Hash, Hasher};
331 let mut hasher = std::collections::hash_map::DefaultHasher::new();
332 key.hash(&mut hasher);
333 format!("{:016x}", hasher.finish())
334 }
335
336 fn migrate_legacy_layout_if_needed(&mut self) {
344 let backups_dir = match self.backups_dir() {
345 Some(d) if d.exists() => d,
346 _ => return,
347 };
348 let default_session_dir =
349 backups_dir.join(Self::session_hash(crate::protocol::DEFAULT_SESSION_ID));
350
351 let entries = match std::fs::read_dir(&backups_dir) {
352 Ok(e) => e,
353 Err(_) => return,
354 };
355 let mut migrated = 0usize;
356 for entry in entries.flatten() {
357 let entry_path = entry.path();
358 if !entry_path.is_dir() {
360 continue;
361 }
362 if entry_path == default_session_dir {
363 continue;
364 }
365 let meta_path = entry_path.join("meta.json");
366 if !meta_path.exists() {
367 continue; }
369 if let Err(e) = std::fs::create_dir_all(&default_session_dir) {
372 log::warn!("[aft] failed to create default session dir: {}", e);
373 return;
374 }
375 let leaf = match entry_path.file_name() {
376 Some(n) => n,
377 None => continue,
378 };
379 let target = default_session_dir.join(leaf);
380 if target.exists() {
381 continue;
384 }
385 match std::fs::rename(&entry_path, &target) {
386 Ok(()) => {
387 Self::upgrade_meta_file(
389 &target.join("meta.json"),
390 crate::protocol::DEFAULT_SESSION_ID,
391 );
392 migrated += 1;
393 }
394 Err(e) => {
395 log::warn!(
396 "[aft] failed to migrate legacy backup {}: {}",
397 entry_path.display(),
398 e
399 );
400 }
401 }
402 }
403 if migrated > 0 {
404 log::info!(
405 "[aft] migrated {} legacy backup entries into default session namespace",
406 migrated
407 );
408 let marker = default_session_dir.join("session.json");
410 let json = serde_json::json!({
411 "schema_version": SCHEMA_VERSION,
412 "session_id": crate::protocol::DEFAULT_SESSION_ID,
413 });
414 if let Ok(s) = serde_json::to_string_pretty(&json) {
415 let _ = std::fs::write(&marker, s);
416 }
417 }
418 }
419
420 fn upgrade_meta_file(meta_path: &Path, session_id: &str) {
421 let content = match std::fs::read_to_string(meta_path) {
422 Ok(c) => c,
423 Err(_) => return,
424 };
425 let mut parsed: serde_json::Value = match serde_json::from_str(&content) {
426 Ok(v) => v,
427 Err(_) => return,
428 };
429 if let Some(obj) = parsed.as_object_mut() {
430 obj.entry("schema_version")
431 .or_insert(serde_json::json!(SCHEMA_VERSION));
432 obj.insert("session_id".to_string(), serde_json::json!(session_id));
433 }
434 if let Ok(s) = serde_json::to_string_pretty(&parsed) {
435 let tmp = meta_path.with_extension("json.tmp");
436 if std::fs::write(&tmp, &s).is_ok() {
437 let _ = std::fs::rename(&tmp, meta_path);
438 }
439 }
440 }
441
442 fn load_disk_index(&mut self) {
443 let backups_dir = match self.backups_dir() {
444 Some(d) if d.exists() => d,
445 _ => return,
446 };
447 let session_dirs = match std::fs::read_dir(&backups_dir) {
448 Ok(e) => e,
449 Err(_) => return,
450 };
451 let mut total_entries = 0usize;
452 for session_entry in session_dirs.flatten() {
453 let session_dir = session_entry.path();
454 if !session_dir.is_dir() {
455 continue;
456 }
457 let session_id = Self::read_session_marker(&session_dir)
460 .unwrap_or_else(|| crate::protocol::DEFAULT_SESSION_ID.to_string());
461
462 let path_dirs = match std::fs::read_dir(&session_dir) {
463 Ok(e) => e,
464 Err(_) => continue,
465 };
466 let per_session = self.disk_index.entry(session_id.clone()).or_default();
467 for path_entry in path_dirs.flatten() {
468 let path_dir = path_entry.path();
469 if !path_dir.is_dir() {
470 continue;
471 }
472 let meta_path = path_dir.join("meta.json");
473 if let Ok(content) = std::fs::read_to_string(&meta_path) {
474 if let Ok(meta) = serde_json::from_str::<serde_json::Value>(&content) {
475 if let (Some(path_str), Some(count)) = (
476 meta.get("path").and_then(|v| v.as_str()),
477 meta.get("count").and_then(|v| v.as_u64()),
478 ) {
479 per_session.insert(
480 PathBuf::from(path_str),
481 DiskMeta {
482 dir: path_dir.clone(),
483 count: count as usize,
484 },
485 );
486 total_entries += 1;
487 }
488 }
489 }
490 }
491 }
492 if total_entries > 0 {
493 log::info!(
494 "[aft] loaded {} backup entries across {} session(s) from disk",
495 total_entries,
496 self.disk_index.len()
497 );
498 }
499 }
500
501 fn read_session_marker(session_dir: &Path) -> Option<String> {
502 let marker = session_dir.join("session.json");
503 let content = std::fs::read_to_string(&marker).ok()?;
504 let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
505 parsed
506 .get("session_id")
507 .and_then(|v| v.as_str())
508 .map(|s| s.to_string())
509 }
510
511 fn load_from_disk_if_needed(&mut self, session: &str, key: &Path) -> bool {
512 let meta = match self
513 .disk_index
514 .get(session)
515 .and_then(|s| s.get(key))
516 .cloned()
517 {
518 Some(m) if m.count > 0 => m,
519 _ => return false,
520 };
521
522 let mut entries = Vec::new();
523 for i in 0..meta.count {
524 let bak_path = meta.dir.join(format!("{}.bak", i));
525 if let Ok(content) = std::fs::read_to_string(&bak_path) {
526 entries.push(BackupEntry {
527 backup_id: format!("disk-{}", i),
528 content,
529 timestamp: 0,
530 description: "restored from disk".to_string(),
531 });
532 }
533 }
534
535 if entries.is_empty() {
536 return false;
537 }
538
539 self.entries
540 .entry(session.to_string())
541 .or_default()
542 .insert(key.to_path_buf(), entries);
543 true
544 }
545
546 fn write_snapshot_to_disk(&mut self, session: &str, key: &Path, stack: &[BackupEntry]) {
547 let session_dir = match self.session_dir(session) {
548 Some(d) => d,
549 None => return,
550 };
551
552 if let Err(e) = std::fs::create_dir_all(&session_dir) {
554 log::warn!("[aft] failed to create session dir: {}", e);
555 return;
556 }
557 let marker = session_dir.join("session.json");
558 if !marker.exists() {
559 let json = serde_json::json!({
560 "schema_version": SCHEMA_VERSION,
561 "session_id": session,
562 });
563 if let Ok(s) = serde_json::to_string_pretty(&json) {
564 let _ = std::fs::write(&marker, s);
565 }
566 }
567
568 let hash = Self::path_hash(key);
569 let dir = session_dir.join(&hash);
570 if let Err(e) = std::fs::create_dir_all(&dir) {
571 log::warn!("[aft] failed to create backup dir: {}", e);
572 return;
573 }
574
575 for (i, entry) in stack.iter().enumerate() {
576 let bak_path = dir.join(format!("{}.bak", i));
577 let tmp_path = dir.join(format!("{}.bak.tmp", i));
578 if std::fs::write(&tmp_path, &entry.content).is_ok() {
579 let _ = std::fs::rename(&tmp_path, &bak_path);
580 }
581 }
582
583 for i in stack.len()..MAX_UNDO_DEPTH {
585 let old = dir.join(format!("{}.bak", i));
586 if old.exists() {
587 let _ = std::fs::remove_file(&old);
588 }
589 }
590
591 let meta = serde_json::json!({
592 "schema_version": SCHEMA_VERSION,
593 "session_id": session,
594 "path": key.display().to_string(),
595 "count": stack.len(),
596 });
597 let meta_path = dir.join("meta.json");
598 let meta_tmp = dir.join("meta.json.tmp");
599 if let Ok(content) = serde_json::to_string_pretty(&meta) {
600 if std::fs::write(&meta_tmp, &content).is_ok() {
601 let _ = std::fs::rename(&meta_tmp, &meta_path);
602 }
603 }
604
605 self.disk_index
608 .entry(session.to_string())
609 .or_default()
610 .insert(
611 key.to_path_buf(),
612 DiskMeta {
613 dir,
614 count: stack.len(),
615 },
616 );
617 }
618
619 fn remove_disk_backups(&mut self, session: &str, key: &Path) {
620 let removed = self.disk_index.get_mut(session).and_then(|s| s.remove(key));
621 if let Some(meta) = removed {
622 let _ = std::fs::remove_dir_all(&meta.dir);
623 } else if let Some(session_dir) = self.session_dir(session) {
624 let hash = Self::path_hash(key);
625 let dir = session_dir.join(&hash);
626 if dir.exists() {
627 let _ = std::fs::remove_dir_all(&dir);
628 }
629 }
630
631 let empty = self
634 .disk_index
635 .get(session)
636 .map(|s| s.is_empty())
637 .unwrap_or(false);
638 if empty {
639 self.disk_index.remove(session);
640 }
641 }
642}
643
644fn canonicalize_key(path: &Path) -> PathBuf {
645 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
646}
647
648fn current_timestamp() -> u64 {
649 std::time::SystemTime::now()
650 .duration_since(std::time::UNIX_EPOCH)
651 .unwrap_or_default()
652 .as_secs()
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658 use crate::protocol::DEFAULT_SESSION_ID;
659 use std::fs;
660
661 fn temp_file(name: &str, content: &str) -> PathBuf {
662 let dir = std::env::temp_dir().join("aft_backup_tests");
663 fs::create_dir_all(&dir).unwrap();
664 let path = dir.join(name);
665 fs::write(&path, content).unwrap();
666 path
667 }
668
669 #[test]
670 fn snapshot_and_restore_round_trip() {
671 let path = temp_file("round_trip.txt", "original");
672 let mut store = BackupStore::new();
673
674 let id = store
675 .snapshot(DEFAULT_SESSION_ID, &path, "before edit")
676 .unwrap();
677 assert!(id.starts_with("backup-"));
678
679 fs::write(&path, "modified").unwrap();
680 assert_eq!(fs::read_to_string(&path).unwrap(), "modified");
681
682 let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
683 assert_eq!(entry.content, "original");
684 assert_eq!(fs::read_to_string(&path).unwrap(), "original");
685 }
686
687 #[test]
688 fn multiple_snapshots_preserve_order() {
689 let path = temp_file("order.txt", "v1");
690 let mut store = BackupStore::new();
691
692 store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
693 fs::write(&path, "v2").unwrap();
694 store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
695 fs::write(&path, "v3").unwrap();
696 store.snapshot(DEFAULT_SESSION_ID, &path, "third").unwrap();
697
698 let history = store.history(DEFAULT_SESSION_ID, &path);
699 assert_eq!(history.len(), 3);
700 assert_eq!(history[0].content, "v1");
701 assert_eq!(history[1].content, "v2");
702 assert_eq!(history[2].content, "v3");
703 }
704
705 #[test]
706 fn restore_pops_from_stack() {
707 let path = temp_file("pop.txt", "v1");
708 let mut store = BackupStore::new();
709
710 store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
711 fs::write(&path, "v2").unwrap();
712 store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
713
714 let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
715 assert_eq!(entry.description, "second");
716 assert_eq!(entry.content, "v2");
717
718 let history = store.history(DEFAULT_SESSION_ID, &path);
719 assert_eq!(history.len(), 1);
720 }
721
722 #[test]
723 fn empty_history_returns_empty_vec() {
724 let store = BackupStore::new();
725 let path = Path::new("/tmp/aft_backup_tests/nonexistent_history.txt");
726 assert!(store.history(DEFAULT_SESSION_ID, path).is_empty());
727 }
728
729 #[test]
730 fn snapshot_nonexistent_file_returns_error() {
731 let mut store = BackupStore::new();
732 let path = Path::new("/tmp/aft_backup_tests/absolutely_does_not_exist.txt");
733 assert!(store.snapshot(DEFAULT_SESSION_ID, path, "test").is_err());
734 }
735
736 #[test]
737 fn tracked_files_lists_snapshotted_paths() {
738 let path1 = temp_file("tracked1.txt", "a");
739 let path2 = temp_file("tracked2.txt", "b");
740 let mut store = BackupStore::new();
741
742 store.snapshot(DEFAULT_SESSION_ID, &path1, "snap1").unwrap();
743 store.snapshot(DEFAULT_SESSION_ID, &path2, "snap2").unwrap();
744 assert_eq!(store.tracked_files(DEFAULT_SESSION_ID).len(), 2);
745 }
746
747 #[test]
748 fn sessions_are_isolated() {
749 let path = temp_file("isolated.txt", "original");
750 let mut store = BackupStore::new();
751
752 store.snapshot("session_a", &path, "a's snapshot").unwrap();
753
754 assert!(store.history("session_b", &path).is_empty());
756 assert_eq!(store.tracked_files("session_b").len(), 0);
757
758 let err = store.restore_latest("session_b", &path);
760 assert!(matches!(err, Err(AftError::NoUndoHistory { .. })));
761
762 assert_eq!(store.history("session_a", &path).len(), 1);
764 assert_eq!(store.tracked_files("session_a").len(), 1);
765 }
766
767 #[test]
768 fn per_session_per_file_cap_is_independent() {
769 let path = temp_file("cap_indep.txt", "v0");
772 let mut store = BackupStore::new();
773
774 for i in 0..(MAX_UNDO_DEPTH + 5) {
775 fs::write(&path, format!("a{}", i)).unwrap();
776 store.snapshot("session_a", &path, "a").unwrap();
777 }
778 fs::write(&path, "b_initial").unwrap();
779 store.snapshot("session_b", &path, "b").unwrap();
780
781 assert_eq!(store.history("session_a", &path).len(), MAX_UNDO_DEPTH);
783 assert_eq!(store.history("session_b", &path).len(), 1);
785 }
786
787 #[test]
788 fn sessions_with_backups_lists_all_namespaces() {
789 let path_a = temp_file("sessions_list_a.txt", "a");
790 let path_b = temp_file("sessions_list_b.txt", "b");
791 let mut store = BackupStore::new();
792
793 store.snapshot("alice", &path_a, "from alice").unwrap();
794 store.snapshot("bob", &path_b, "from bob").unwrap();
795
796 let sessions = store.sessions_with_backups();
797 assert_eq!(sessions.len(), 2);
798 assert!(sessions.iter().any(|s| s == "alice"));
799 assert!(sessions.iter().any(|s| s == "bob"));
800 }
801
802 #[test]
803 fn disk_persistence_survives_reload() {
804 let dir = std::env::temp_dir().join("aft_backup_disk_test");
805 let _ = fs::remove_dir_all(&dir);
806 fs::create_dir_all(&dir).unwrap();
807
808 let file_path = temp_file("disk_persist.txt", "original");
809
810 {
812 let mut store = BackupStore::new();
813 store.set_storage_dir(dir.clone());
814 store
815 .snapshot(DEFAULT_SESSION_ID, &file_path, "before edit")
816 .unwrap();
817 }
818
819 fs::write(&file_path, "externally modified").unwrap();
821
822 let mut store2 = BackupStore::new();
824 store2.set_storage_dir(dir.clone());
825
826 let (entry, warning) = store2
827 .restore_latest(DEFAULT_SESSION_ID, &file_path)
828 .unwrap();
829 assert_eq!(entry.content, "original");
830 assert!(warning.is_some()); assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
832
833 let _ = fs::remove_dir_all(&dir);
834 }
835
836 #[test]
837 fn legacy_flat_layout_migrates_to_default_session() {
838 let dir = std::env::temp_dir().join("aft_backup_migration_test");
841 let _ = fs::remove_dir_all(&dir);
842 fs::create_dir_all(&dir).unwrap();
843 let backups = dir.join("backups");
844 fs::create_dir_all(&backups).unwrap();
845
846 let legacy_hash = "deadbeefcafebabe";
848 let legacy_dir = backups.join(legacy_hash);
849 fs::create_dir_all(&legacy_dir).unwrap();
850 fs::write(legacy_dir.join("0.bak"), "original content").unwrap();
851 let legacy_meta = serde_json::json!({
852 "path": "/tmp/migrated_file.txt",
853 "count": 1,
854 });
855 fs::write(
856 legacy_dir.join("meta.json"),
857 serde_json::to_string_pretty(&legacy_meta).unwrap(),
858 )
859 .unwrap();
860
861 let mut store = BackupStore::new();
863 store.set_storage_dir(dir.clone());
864
865 let default_session_dir = backups.join(BackupStore::session_hash(DEFAULT_SESSION_ID));
868 assert!(default_session_dir.exists());
869 assert!(default_session_dir.join(legacy_hash).exists());
870 assert!(!backups.join(legacy_hash).exists());
871
872 let meta_content =
874 fs::read_to_string(default_session_dir.join(legacy_hash).join("meta.json")).unwrap();
875 let meta: serde_json::Value = serde_json::from_str(&meta_content).unwrap();
876 assert_eq!(meta["session_id"], DEFAULT_SESSION_ID);
877 assert_eq!(meta["schema_version"], SCHEMA_VERSION);
878
879 let _ = fs::remove_dir_all(&dir);
880 }
881}