1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4
5use crate::error::AftError;
6use sha2::{Digest, Sha256};
7
8const MAX_UNDO_DEPTH: usize = 20;
9
10const SCHEMA_VERSION: u32 = 2;
15
16#[derive(Debug, Clone)]
18pub struct BackupEntry {
19 pub backup_id: String,
20 pub content: String,
21 pub timestamp: u64,
22 pub description: String,
23}
24
25#[derive(Debug)]
44pub struct BackupStore {
45 entries: HashMap<String, HashMap<PathBuf, Vec<BackupEntry>>>,
47 disk_index: HashMap<String, HashMap<PathBuf, DiskMeta>>,
49 session_meta: HashMap<String, SessionMeta>,
51 counter: AtomicU64,
52 storage_dir: Option<PathBuf>,
53}
54
55#[derive(Debug, Clone)]
56struct DiskMeta {
57 dir: PathBuf,
58 count: usize,
59}
60
61#[derive(Debug, Clone, Default)]
62struct SessionMeta {
63 last_accessed: u64,
66}
67
68impl BackupStore {
69 pub fn new() -> Self {
70 BackupStore {
71 entries: HashMap::new(),
72 disk_index: HashMap::new(),
73 session_meta: HashMap::new(),
74 counter: AtomicU64::new(0),
75 storage_dir: None,
76 }
77 }
78
79 pub fn set_storage_dir(&mut self, dir: PathBuf, ttl_hours: u32) {
85 self.storage_dir = Some(dir);
86 self.gc_stale_sessions(ttl_hours);
87 self.migrate_legacy_layout_if_needed();
88 self.load_disk_index();
89 }
90
91 pub fn snapshot(
93 &mut self,
94 session: &str,
95 path: &Path,
96 description: &str,
97 ) -> Result<String, AftError> {
98 let content = std::fs::read_to_string(path).map_err(|_| AftError::FileNotFound {
99 path: path.display().to_string(),
100 })?;
101
102 let key = canonicalize_key(path);
103 let id = self.next_id();
104 let entry = BackupEntry {
105 backup_id: id.clone(),
106 content,
107 timestamp: current_timestamp(),
108 description: description.to_string(),
109 };
110
111 let session_entries = self.entries.entry(session.to_string()).or_default();
112 let stack = session_entries.entry(key.clone()).or_default();
113 if stack.len() >= MAX_UNDO_DEPTH {
114 stack.remove(0);
115 }
116 stack.push(entry);
117
118 let stack_clone = stack.clone();
120 self.write_snapshot_to_disk(session, &key, &stack_clone);
121 self.touch_session(session);
122
123 Ok(id)
124 }
125
126 pub fn restore_latest(
129 &mut self,
130 session: &str,
131 path: &Path,
132 ) -> Result<(BackupEntry, Option<String>), AftError> {
133 let key = canonicalize_key(path);
134
135 let in_memory = self
137 .entries
138 .get(session)
139 .and_then(|s| s.get(&key))
140 .map_or(false, |s| !s.is_empty());
141 if in_memory {
142 let result = self.do_restore(session, &key, path);
143 if result.is_ok() {
144 self.touch_session(session);
145 }
146 return result;
147 }
148
149 if self.load_from_disk_if_needed(session, &key) {
151 let warning = self.check_external_modification(session, &key, path);
153 let (entry, _) = self.do_restore(session, &key, path)?;
154 self.touch_session(session);
155 return Ok((entry, warning));
156 }
157
158 Err(AftError::NoUndoHistory {
159 path: path.display().to_string(),
160 })
161 }
162
163 pub fn history(&self, session: &str, path: &Path) -> Vec<BackupEntry> {
165 let key = canonicalize_key(path);
166 self.entries
167 .get(session)
168 .and_then(|s| s.get(&key))
169 .cloned()
170 .unwrap_or_default()
171 }
172
173 pub fn disk_history_count(&self, session: &str, path: &Path) -> usize {
175 let key = canonicalize_key(path);
176 self.disk_index
177 .get(session)
178 .and_then(|s| s.get(&key))
179 .map(|m| m.count)
180 .unwrap_or(0)
181 }
182
183 pub fn tracked_files(&self, session: &str) -> Vec<PathBuf> {
186 let mut files: std::collections::HashSet<PathBuf> = self
187 .entries
188 .get(session)
189 .map(|s| s.keys().cloned().collect())
190 .unwrap_or_default();
191 if let Some(disk) = self.disk_index.get(session) {
192 for key in disk.keys() {
193 files.insert(key.clone());
194 }
195 }
196 files.into_iter().collect()
197 }
198
199 pub fn sessions_with_backups(&self) -> Vec<String> {
202 let mut sessions: std::collections::HashSet<String> =
203 self.entries.keys().cloned().collect();
204 for s in self.disk_index.keys() {
205 sessions.insert(s.clone());
206 }
207 sessions.into_iter().collect()
208 }
209
210 pub fn total_disk_bytes(&self) -> u64 {
213 let mut total = 0u64;
214 for session_dirs in self.disk_index.values() {
215 for meta in session_dirs.values() {
216 if let Ok(read_dir) = std::fs::read_dir(&meta.dir) {
217 for entry in read_dir.flatten() {
218 if let Ok(m) = entry.metadata() {
219 if m.is_file() {
220 total += m.len();
221 }
222 }
223 }
224 }
225 }
226 }
227 total
228 }
229
230 fn next_id(&self) -> String {
231 let n = self.counter.fetch_add(1, Ordering::Relaxed);
232 format!("backup-{}", n)
233 }
234
235 fn touch_session(&mut self, session: &str) {
236 let now = current_timestamp();
237 self.session_meta
238 .entry(session.to_string())
239 .or_default()
240 .last_accessed = now;
241 self.write_session_marker(session, now);
242 }
243
244 fn do_restore(
247 &mut self,
248 session: &str,
249 key: &Path,
250 path: &Path,
251 ) -> Result<(BackupEntry, Option<String>), AftError> {
252 let session_entries =
253 self.entries
254 .get_mut(session)
255 .ok_or_else(|| AftError::NoUndoHistory {
256 path: path.display().to_string(),
257 })?;
258 let stack = session_entries
259 .get_mut(key)
260 .ok_or_else(|| AftError::NoUndoHistory {
261 path: path.display().to_string(),
262 })?;
263
264 let entry = stack
265 .last()
266 .cloned()
267 .ok_or_else(|| AftError::NoUndoHistory {
268 path: path.display().to_string(),
269 })?;
270
271 std::fs::write(path, &entry.content).map_err(|e| AftError::IoError {
272 path: path.display().to_string(),
273 message: e.to_string(),
274 })?;
275
276 stack.pop();
277 if stack.is_empty() {
278 session_entries.remove(key);
279 if session_entries.is_empty() {
281 self.entries.remove(session);
282 }
283 self.remove_disk_backups(session, key);
284 } else {
285 let stack_clone = self
286 .entries
287 .get(session)
288 .and_then(|s| s.get(key))
289 .cloned()
290 .unwrap_or_default();
291 self.write_snapshot_to_disk(session, key, &stack_clone);
292 }
293
294 Ok((entry, None))
295 }
296
297 fn check_external_modification(
298 &self,
299 session: &str,
300 key: &Path,
301 path: &Path,
302 ) -> Option<String> {
303 if let (Some(stack), Ok(current)) = (
304 self.entries.get(session).and_then(|s| s.get(key)),
305 std::fs::read_to_string(path),
306 ) {
307 if let Some(latest) = stack.last() {
308 if latest.content != current {
309 return Some("file was modified externally since last backup".to_string());
310 }
311 }
312 }
313 None
314 }
315
316 fn backups_dir(&self) -> Option<PathBuf> {
319 self.storage_dir.as_ref().map(|d| d.join("backups"))
320 }
321
322 fn session_dir(&self, session: &str) -> Option<PathBuf> {
323 self.backups_dir()
324 .map(|d| d.join(Self::session_hash(session)))
325 }
326
327 fn session_hash(session: &str) -> String {
328 hash_session(session)
329 }
330
331 fn path_hash(key: &Path) -> String {
332 stable_hash_16(key.to_string_lossy().as_bytes())
337 }
338
339 fn write_session_marker(&self, session: &str, last_accessed: u64) {
340 let Some(session_dir) = self.session_dir(session) else {
341 return;
342 };
343 if let Err(e) = std::fs::create_dir_all(&session_dir) {
344 log::warn!("failed to create session dir: {}", e);
345 return;
346 }
347 let marker = session_dir.join("session.json");
348 let json = serde_json::json!({
349 "schema_version": SCHEMA_VERSION,
350 "session_id": session,
351 "last_accessed": last_accessed,
352 });
353 if let Ok(s) = serde_json::to_string_pretty(&json) {
354 let tmp = session_dir.join("session.json.tmp");
355 if std::fs::write(&tmp, s).is_ok() {
356 let _ = std::fs::rename(&tmp, marker);
357 }
358 }
359 }
360
361 fn gc_stale_sessions(&mut self, ttl_hours: u32) {
362 let backups_dir = match self.backups_dir() {
363 Some(d) if d.exists() => d,
364 _ => return,
365 };
366 let ttl_secs = u64::from(if ttl_hours == 0 { 72 } else { ttl_hours }) * 60 * 60;
367 let cutoff = current_timestamp().saturating_sub(ttl_secs);
368 let entries = match std::fs::read_dir(&backups_dir) {
369 Ok(entries) => entries,
370 Err(_) => return,
371 };
372
373 for entry in entries.flatten() {
374 let session_dir = entry.path();
375 if !session_dir.is_dir() || session_dir.join("meta.json").exists() {
376 continue;
377 }
378 let Some(last_accessed) = Self::read_session_last_accessed(&session_dir) else {
379 continue;
380 };
381 if last_accessed >= cutoff {
382 continue;
383 }
384 if let Err(e) = std::fs::remove_dir_all(&session_dir) {
385 log::warn!(
386 "failed to remove stale backup session {}: {}",
387 session_dir.display(),
388 e
389 );
390 } else {
391 log::warn!(
392 "removed stale backup session {} (last_accessed={})",
393 session_dir.display(),
394 last_accessed
395 );
396 }
397 }
398 }
399
400 fn migrate_legacy_layout_if_needed(&mut self) {
408 let backups_dir = match self.backups_dir() {
409 Some(d) if d.exists() => d,
410 _ => return,
411 };
412 let default_session_dir =
413 backups_dir.join(Self::session_hash(crate::protocol::DEFAULT_SESSION_ID));
414
415 let entries = match std::fs::read_dir(&backups_dir) {
416 Ok(e) => e,
417 Err(_) => return,
418 };
419 let mut migrated = 0usize;
420 for entry in entries.flatten() {
421 let entry_path = entry.path();
422 if !entry_path.is_dir() {
424 continue;
425 }
426 if entry_path == default_session_dir {
427 continue;
428 }
429 let meta_path = entry_path.join("meta.json");
430 if !meta_path.exists() {
431 continue; }
433 if let Err(e) = std::fs::create_dir_all(&default_session_dir) {
436 log::warn!("failed to create default session dir: {}", e);
437 return;
438 }
439 let leaf = match entry_path.file_name() {
440 Some(n) => n,
441 None => continue,
442 };
443 let target = default_session_dir.join(leaf);
444 if target.exists() {
445 continue;
448 }
449 match std::fs::rename(&entry_path, &target) {
450 Ok(()) => {
451 Self::upgrade_meta_file(
453 &target.join("meta.json"),
454 crate::protocol::DEFAULT_SESSION_ID,
455 );
456 migrated += 1;
457 }
458 Err(e) => {
459 log::warn!(
460 "failed to migrate legacy backup {}: {}",
461 entry_path.display(),
462 e
463 );
464 }
465 }
466 }
467 if migrated > 0 {
468 log::info!(
469 "migrated {} legacy backup entries into default session namespace",
470 migrated
471 );
472 let marker = default_session_dir.join("session.json");
474 let json = serde_json::json!({
475 "schema_version": SCHEMA_VERSION,
476 "session_id": crate::protocol::DEFAULT_SESSION_ID,
477 "last_accessed": current_timestamp(),
478 });
479 if let Ok(s) = serde_json::to_string_pretty(&json) {
480 let _ = std::fs::write(&marker, s);
481 }
482 }
483 }
484
485 fn upgrade_meta_file(meta_path: &Path, session_id: &str) {
486 let content = match std::fs::read_to_string(meta_path) {
487 Ok(c) => c,
488 Err(_) => return,
489 };
490 let mut parsed: serde_json::Value = match serde_json::from_str(&content) {
491 Ok(v) => v,
492 Err(_) => return,
493 };
494 if let Some(obj) = parsed.as_object_mut() {
495 obj.entry("schema_version")
496 .or_insert(serde_json::json!(SCHEMA_VERSION));
497 obj.insert("session_id".to_string(), serde_json::json!(session_id));
498 }
499 if let Ok(s) = serde_json::to_string_pretty(&parsed) {
500 let tmp = meta_path.with_extension("json.tmp");
501 if std::fs::write(&tmp, &s).is_ok() {
502 let _ = std::fs::rename(&tmp, meta_path);
503 }
504 }
505 }
506
507 fn load_disk_index(&mut self) {
508 let backups_dir = match self.backups_dir() {
509 Some(d) if d.exists() => d,
510 _ => return,
511 };
512 let session_dirs = match std::fs::read_dir(&backups_dir) {
513 Ok(e) => e,
514 Err(_) => return,
515 };
516 let mut total_entries = 0usize;
517 for session_entry in session_dirs.flatten() {
518 let session_dir = session_entry.path();
519 if !session_dir.is_dir() {
520 continue;
521 }
522 let session_id = Self::read_session_marker(&session_dir)
525 .unwrap_or_else(|| crate::protocol::DEFAULT_SESSION_ID.to_string());
526
527 let path_dirs = match std::fs::read_dir(&session_dir) {
528 Ok(e) => e,
529 Err(_) => continue,
530 };
531 let per_session = self.disk_index.entry(session_id.clone()).or_default();
532 for path_entry in path_dirs.flatten() {
533 let path_dir = path_entry.path();
534 if !path_dir.is_dir() {
535 continue;
536 }
537 let meta_path = path_dir.join("meta.json");
538 if let Ok(content) = std::fs::read_to_string(&meta_path) {
539 if let Ok(meta) = serde_json::from_str::<serde_json::Value>(&content) {
540 if let (Some(path_str), Some(count)) = (
541 meta.get("path").and_then(|v| v.as_str()),
542 meta.get("count").and_then(|v| v.as_u64()),
543 ) {
544 per_session.insert(
545 PathBuf::from(path_str),
546 DiskMeta {
547 dir: path_dir.clone(),
548 count: count as usize,
549 },
550 );
551 total_entries += 1;
552 }
553 }
554 }
555 }
556 }
557 if total_entries > 0 {
558 log::info!(
559 "loaded {} backup entries across {} session(s) from disk",
560 total_entries,
561 self.disk_index.len()
562 );
563 }
564 }
565
566 fn read_session_marker(session_dir: &Path) -> Option<String> {
567 let marker = session_dir.join("session.json");
568 let content = std::fs::read_to_string(&marker).ok()?;
569 let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
570 parsed
571 .get("session_id")
572 .and_then(|v| v.as_str())
573 .map(|s| s.to_string())
574 }
575
576 fn read_session_last_accessed(session_dir: &Path) -> Option<u64> {
577 let marker = session_dir.join("session.json");
578 let content = std::fs::read_to_string(&marker).ok()?;
579 let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
580 parsed.get("last_accessed").and_then(|v| v.as_u64())
581 }
582
583 fn load_from_disk_if_needed(&mut self, session: &str, key: &Path) -> bool {
584 let meta = match self
585 .disk_index
586 .get(session)
587 .and_then(|s| s.get(key))
588 .cloned()
589 {
590 Some(m) if m.count > 0 => m,
591 _ => return false,
592 };
593
594 let mut entries = Vec::new();
595 for i in 0..meta.count {
596 let bak_path = meta.dir.join(format!("{}.bak", i));
597 if let Ok(content) = std::fs::read_to_string(&bak_path) {
598 entries.push(BackupEntry {
599 backup_id: format!("disk-{}", i),
600 content,
601 timestamp: 0,
602 description: "restored from disk".to_string(),
603 });
604 }
605 }
606
607 if entries.is_empty() {
608 return false;
609 }
610
611 self.entries
612 .entry(session.to_string())
613 .or_default()
614 .insert(key.to_path_buf(), entries);
615 true
616 }
617
618 fn write_snapshot_to_disk(&mut self, session: &str, key: &Path, stack: &[BackupEntry]) {
619 let session_dir = match self.session_dir(session) {
620 Some(d) => d,
621 None => return,
622 };
623
624 if let Err(e) = std::fs::create_dir_all(&session_dir) {
626 log::warn!("failed to create session dir: {}", e);
627 return;
628 }
629 let marker = session_dir.join("session.json");
630 if !marker.exists() {
631 let json = serde_json::json!({
632 "schema_version": SCHEMA_VERSION,
633 "session_id": session,
634 "last_accessed": current_timestamp(),
635 });
636 if let Ok(s) = serde_json::to_string_pretty(&json) {
637 let _ = std::fs::write(&marker, s);
638 }
639 }
640
641 let hash = Self::path_hash(key);
642 let dir = session_dir.join(&hash);
643 if let Err(e) = std::fs::create_dir_all(&dir) {
644 log::warn!("failed to create backup dir: {}", e);
645 return;
646 }
647
648 for (i, entry) in stack.iter().enumerate() {
649 let bak_path = dir.join(format!("{}.bak", i));
650 let tmp_path = dir.join(format!("{}.bak.tmp", i));
651 if std::fs::write(&tmp_path, &entry.content).is_ok() {
652 let _ = std::fs::rename(&tmp_path, &bak_path);
653 }
654 }
655
656 for i in stack.len()..MAX_UNDO_DEPTH {
658 let old = dir.join(format!("{}.bak", i));
659 if old.exists() {
660 let _ = std::fs::remove_file(&old);
661 }
662 }
663
664 let meta = serde_json::json!({
665 "schema_version": SCHEMA_VERSION,
666 "session_id": session,
667 "path": key.display().to_string(),
668 "count": stack.len(),
669 });
670 let meta_path = dir.join("meta.json");
671 let meta_tmp = dir.join("meta.json.tmp");
672 if let Ok(content) = serde_json::to_string_pretty(&meta) {
673 if std::fs::write(&meta_tmp, &content).is_ok() {
674 let _ = std::fs::rename(&meta_tmp, &meta_path);
675 }
676 }
677
678 self.disk_index
681 .entry(session.to_string())
682 .or_default()
683 .insert(
684 key.to_path_buf(),
685 DiskMeta {
686 dir,
687 count: stack.len(),
688 },
689 );
690 }
691
692 fn remove_disk_backups(&mut self, session: &str, key: &Path) {
693 let removed = self.disk_index.get_mut(session).and_then(|s| s.remove(key));
694 if let Some(meta) = removed {
695 let _ = std::fs::remove_dir_all(&meta.dir);
696 } else if let Some(session_dir) = self.session_dir(session) {
697 let hash = Self::path_hash(key);
698 let dir = session_dir.join(&hash);
699 if dir.exists() {
700 let _ = std::fs::remove_dir_all(&dir);
701 }
702 }
703
704 let empty = self
707 .disk_index
708 .get(session)
709 .map(|s| s.is_empty())
710 .unwrap_or(false);
711 if empty {
712 self.disk_index.remove(session);
713 }
714 }
715}
716
717pub fn hash_session(session: &str) -> String {
718 stable_hash_16(session.as_bytes())
719}
720
721fn canonicalize_key(path: &Path) -> PathBuf {
722 std::fs::canonicalize(path).unwrap_or_else(|err| {
723 log::debug!(
724 "backup canonicalize_key fallback for {}: {}",
725 path.display(),
726 err
727 );
728 path.to_path_buf()
729 })
730}
731
732fn current_timestamp() -> u64 {
733 std::time::SystemTime::now()
734 .duration_since(std::time::UNIX_EPOCH)
735 .unwrap_or_default()
736 .as_secs()
737}
738
739fn stable_hash_16(bytes: &[u8]) -> String {
740 let digest = Sha256::digest(bytes);
741 digest[..8]
742 .iter()
743 .map(|byte| format!("{:02x}", byte))
744 .collect()
745}
746
747#[cfg(test)]
748mod tests {
749 use super::*;
750 use crate::protocol::DEFAULT_SESSION_ID;
751 use std::fs;
752
753 fn temp_file(name: &str, content: &str) -> PathBuf {
754 let dir = std::env::temp_dir().join("aft_backup_tests");
755 fs::create_dir_all(&dir).unwrap();
756 let path = dir.join(name);
757 fs::write(&path, content).unwrap();
758 path
759 }
760
761 #[test]
762 fn snapshot_and_restore_round_trip() {
763 let path = temp_file("round_trip.txt", "original");
764 let mut store = BackupStore::new();
765
766 let id = store
767 .snapshot(DEFAULT_SESSION_ID, &path, "before edit")
768 .unwrap();
769 assert!(id.starts_with("backup-"));
770
771 fs::write(&path, "modified").unwrap();
772 assert_eq!(fs::read_to_string(&path).unwrap(), "modified");
773
774 let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
775 assert_eq!(entry.content, "original");
776 assert_eq!(fs::read_to_string(&path).unwrap(), "original");
777 }
778
779 #[test]
780 fn multiple_snapshots_preserve_order() {
781 let path = temp_file("order.txt", "v1");
782 let mut store = BackupStore::new();
783
784 store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
785 fs::write(&path, "v2").unwrap();
786 store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
787 fs::write(&path, "v3").unwrap();
788 store.snapshot(DEFAULT_SESSION_ID, &path, "third").unwrap();
789
790 let history = store.history(DEFAULT_SESSION_ID, &path);
791 assert_eq!(history.len(), 3);
792 assert_eq!(history[0].content, "v1");
793 assert_eq!(history[1].content, "v2");
794 assert_eq!(history[2].content, "v3");
795 }
796
797 #[test]
798 fn restore_pops_from_stack() {
799 let path = temp_file("pop.txt", "v1");
800 let mut store = BackupStore::new();
801
802 store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
803 fs::write(&path, "v2").unwrap();
804 store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
805
806 let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
807 assert_eq!(entry.description, "second");
808 assert_eq!(entry.content, "v2");
809
810 let history = store.history(DEFAULT_SESSION_ID, &path);
811 assert_eq!(history.len(), 1);
812 }
813
814 #[test]
815 fn empty_history_returns_empty_vec() {
816 let store = BackupStore::new();
817 let path = Path::new("/tmp/aft_backup_tests/nonexistent_history.txt");
818 assert!(store.history(DEFAULT_SESSION_ID, path).is_empty());
819 }
820
821 #[test]
822 fn snapshot_nonexistent_file_returns_error() {
823 let mut store = BackupStore::new();
824 let path = Path::new("/tmp/aft_backup_tests/absolutely_does_not_exist.txt");
825 assert!(store.snapshot(DEFAULT_SESSION_ID, path, "test").is_err());
826 }
827
828 #[test]
829 fn tracked_files_lists_snapshotted_paths() {
830 let path1 = temp_file("tracked1.txt", "a");
831 let path2 = temp_file("tracked2.txt", "b");
832 let mut store = BackupStore::new();
833
834 store.snapshot(DEFAULT_SESSION_ID, &path1, "snap1").unwrap();
835 store.snapshot(DEFAULT_SESSION_ID, &path2, "snap2").unwrap();
836 assert_eq!(store.tracked_files(DEFAULT_SESSION_ID).len(), 2);
837 }
838
839 #[test]
840 fn sessions_are_isolated() {
841 let path = temp_file("isolated.txt", "original");
842 let mut store = BackupStore::new();
843
844 store.snapshot("session_a", &path, "a's snapshot").unwrap();
845
846 assert!(store.history("session_b", &path).is_empty());
848 assert_eq!(store.tracked_files("session_b").len(), 0);
849
850 let err = store.restore_latest("session_b", &path);
852 assert!(matches!(err, Err(AftError::NoUndoHistory { .. })));
853
854 assert_eq!(store.history("session_a", &path).len(), 1);
856 assert_eq!(store.tracked_files("session_a").len(), 1);
857 }
858
859 #[test]
860 fn per_session_per_file_cap_is_independent() {
861 let path = temp_file("cap_indep.txt", "v0");
864 let mut store = BackupStore::new();
865
866 for i in 0..(MAX_UNDO_DEPTH + 5) {
867 fs::write(&path, format!("a{}", i)).unwrap();
868 store.snapshot("session_a", &path, "a").unwrap();
869 }
870 fs::write(&path, "b_initial").unwrap();
871 store.snapshot("session_b", &path, "b").unwrap();
872
873 assert_eq!(store.history("session_a", &path).len(), MAX_UNDO_DEPTH);
875 assert_eq!(store.history("session_b", &path).len(), 1);
877 }
878
879 #[test]
880 fn sessions_with_backups_lists_all_namespaces() {
881 let path_a = temp_file("sessions_list_a.txt", "a");
882 let path_b = temp_file("sessions_list_b.txt", "b");
883 let mut store = BackupStore::new();
884
885 store.snapshot("alice", &path_a, "from alice").unwrap();
886 store.snapshot("bob", &path_b, "from bob").unwrap();
887
888 let sessions = store.sessions_with_backups();
889 assert_eq!(sessions.len(), 2);
890 assert!(sessions.iter().any(|s| s == "alice"));
891 assert!(sessions.iter().any(|s| s == "bob"));
892 }
893
894 #[test]
895 fn disk_persistence_survives_reload() {
896 let dir = std::env::temp_dir().join("aft_backup_disk_test");
897 let _ = fs::remove_dir_all(&dir);
898 fs::create_dir_all(&dir).unwrap();
899
900 let file_path = temp_file("disk_persist.txt", "original");
901
902 {
904 let mut store = BackupStore::new();
905 store.set_storage_dir(dir.clone(), 72);
906 store
907 .snapshot(DEFAULT_SESSION_ID, &file_path, "before edit")
908 .unwrap();
909 }
910
911 fs::write(&file_path, "externally modified").unwrap();
913
914 let mut store2 = BackupStore::new();
916 store2.set_storage_dir(dir.clone(), 72);
917
918 let (entry, warning) = store2
919 .restore_latest(DEFAULT_SESSION_ID, &file_path)
920 .unwrap();
921 assert_eq!(entry.content, "original");
922 assert!(warning.is_some()); assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
924
925 let _ = fs::remove_dir_all(&dir);
926 }
927
928 #[test]
929 fn legacy_flat_layout_migrates_to_default_session() {
930 let dir = std::env::temp_dir().join("aft_backup_migration_test");
933 let _ = fs::remove_dir_all(&dir);
934 fs::create_dir_all(&dir).unwrap();
935 let backups = dir.join("backups");
936 fs::create_dir_all(&backups).unwrap();
937
938 let legacy_hash = "deadbeefcafebabe";
940 let legacy_dir = backups.join(legacy_hash);
941 fs::create_dir_all(&legacy_dir).unwrap();
942 fs::write(legacy_dir.join("0.bak"), "original content").unwrap();
943 let legacy_meta = serde_json::json!({
944 "path": "/tmp/migrated_file.txt",
945 "count": 1,
946 });
947 fs::write(
948 legacy_dir.join("meta.json"),
949 serde_json::to_string_pretty(&legacy_meta).unwrap(),
950 )
951 .unwrap();
952
953 let mut store = BackupStore::new();
955 store.set_storage_dir(dir.clone(), 72);
956
957 let default_session_dir = backups.join(BackupStore::session_hash(DEFAULT_SESSION_ID));
960 assert!(default_session_dir.exists());
961 assert!(default_session_dir.join(legacy_hash).exists());
962 assert!(!backups.join(legacy_hash).exists());
963
964 let meta_content =
966 fs::read_to_string(default_session_dir.join(legacy_hash).join("meta.json")).unwrap();
967 let meta: serde_json::Value = serde_json::from_str(&meta_content).unwrap();
968 assert_eq!(meta["session_id"], DEFAULT_SESSION_ID);
969 assert_eq!(meta["schema_version"], SCHEMA_VERSION);
970
971 let _ = fs::remove_dir_all(&dir);
972 }
973
974 #[test]
975 fn set_storage_dir_removes_stale_backup_sessions() {
976 let dir = std::env::temp_dir().join("aft_backup_gc_test");
977 let _ = fs::remove_dir_all(&dir);
978 let backups = dir.join("backups");
979 fs::create_dir_all(&backups).unwrap();
980
981 let stale_session_dir = backups.join("stale-session");
982 fs::create_dir_all(&stale_session_dir).unwrap();
983 let stale_marker = serde_json::json!({
984 "schema_version": SCHEMA_VERSION,
985 "session_id": "stale",
986 "last_accessed": 1,
987 });
988 fs::write(
989 stale_session_dir.join("session.json"),
990 serde_json::to_string_pretty(&stale_marker).unwrap(),
991 )
992 .unwrap();
993
994 let mut store = BackupStore::new();
995 store.set_storage_dir(dir.clone(), 1);
996
997 assert!(!stale_session_dir.exists());
998 let _ = fs::remove_dir_all(&dir);
999 }
1000}