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 = 3;
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 pub op_id: Option<String>,
24}
25
26#[derive(Debug, Clone)]
27pub struct RestoredOperation {
28 pub op_id: String,
29 pub restored: Vec<RestoredFile>,
30 pub warnings: Vec<String>,
31}
32
33#[derive(Debug, Clone)]
34pub struct RestoredFile {
35 pub path: PathBuf,
36 pub backup_id: String,
37}
38
39#[derive(Debug)]
58pub struct BackupStore {
59 entries: HashMap<String, HashMap<PathBuf, Vec<BackupEntry>>>,
61 disk_index: HashMap<String, HashMap<PathBuf, DiskMeta>>,
63 session_meta: HashMap<String, SessionMeta>,
65 counter: AtomicU64,
66 storage_dir: Option<PathBuf>,
67}
68
69#[derive(Debug, Clone)]
70struct DiskMeta {
71 dir: PathBuf,
72 count: usize,
73}
74
75#[derive(Debug, Clone, Default)]
76struct SessionMeta {
77 last_accessed: u64,
80}
81
82impl BackupStore {
83 pub fn new() -> Self {
84 BackupStore {
85 entries: HashMap::new(),
86 disk_index: HashMap::new(),
87 session_meta: HashMap::new(),
88 counter: AtomicU64::new(0),
89 storage_dir: None,
90 }
91 }
92
93 pub fn set_storage_dir(&mut self, dir: PathBuf, ttl_hours: u32) {
99 self.storage_dir = Some(dir);
100 self.gc_stale_sessions(ttl_hours);
101 self.migrate_legacy_layout_if_needed();
102 self.load_disk_index();
103 }
104
105 pub fn snapshot(
107 &mut self,
108 session: &str,
109 path: &Path,
110 description: &str,
111 ) -> Result<String, AftError> {
112 self.snapshot_with_op(session, path, description, None)
113 }
114
115 pub fn snapshot_with_op(
119 &mut self,
120 session: &str,
121 path: &Path,
122 description: &str,
123 op_id: Option<&str>,
124 ) -> Result<String, AftError> {
125 let content = std::fs::read_to_string(path).map_err(|_| AftError::FileNotFound {
126 path: path.display().to_string(),
127 })?;
128
129 let key = canonicalize_key(path);
130 let id = self.next_id();
131 let entry = BackupEntry {
132 backup_id: id.clone(),
133 content,
134 timestamp: current_timestamp(),
135 description: description.to_string(),
136 op_id: op_id.map(str::to_string),
137 };
138
139 let session_entries = self.entries.entry(session.to_string()).or_default();
140 let stack = session_entries.entry(key.clone()).or_default();
141 if stack.len() >= MAX_UNDO_DEPTH {
142 stack.remove(0);
143 }
144 stack.push(entry);
145
146 let stack_clone = stack.clone();
148 self.write_snapshot_to_disk(session, &key, &stack_clone);
149 self.touch_session(session);
150
151 Ok(id)
152 }
153
154 pub fn restore_last_operation(&mut self, session: &str) -> Result<RestoredOperation, AftError> {
157 let disk_keys: Vec<PathBuf> = self
158 .disk_index
159 .get(session)
160 .map(|files| files.keys().cloned().collect())
161 .unwrap_or_default();
162 for key in disk_keys {
163 self.load_from_disk_if_needed(session, &key);
164 }
165
166 let mut latest: Option<((u64, u64), String)> = None;
167 if let Some(files) = self.entries.get(session) {
168 for stack in files.values() {
169 for entry in stack {
170 if let Some(op_id) = &entry.op_id {
171 let seq = backup_sequence(&entry.backup_id).unwrap_or(0);
172 let order = (entry.timestamp, seq);
173 if latest
174 .as_ref()
175 .map_or(true, |(latest_order, _)| order > *latest_order)
176 {
177 latest = Some((order, op_id.clone()));
178 }
179 }
180 }
181 }
182 }
183
184 let Some((_, op_id)) = latest else {
185 return Err(AftError::NoUndoHistory {
186 path: "operation".to_string(),
187 });
188 };
189
190 let mut keys_to_restore: Vec<PathBuf> = self
191 .entries
192 .get(session)
193 .map(|files| {
194 files
195 .iter()
196 .filter_map(|(key, stack)| {
197 stack.last().and_then(|entry| {
198 (entry.op_id.as_deref() == Some(op_id.as_str())).then(|| key.clone())
199 })
200 })
201 .collect()
202 })
203 .unwrap_or_default();
204 keys_to_restore.sort();
205
206 if keys_to_restore.is_empty() {
207 return Err(AftError::NoUndoHistory {
208 path: "operation".to_string(),
209 });
210 }
211
212 let mut targets = Vec::new();
213 for key in &keys_to_restore {
214 let entry = self
215 .entries
216 .get(session)
217 .and_then(|files| files.get(key))
218 .and_then(|stack| stack.last())
219 .cloned()
220 .ok_or_else(|| AftError::NoUndoHistory {
221 path: key.display().to_string(),
222 })?;
223 let existing_content = match std::fs::read(key) {
224 Ok(content) => Some(content),
225 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
226 Err(e) => {
227 return Err(AftError::IoError {
228 path: key.display().to_string(),
229 message: e.to_string(),
230 });
231 }
232 };
233 let warning = self.check_external_modification(session, key, key);
234 targets.push((key.clone(), entry, warning, existing_content));
235 }
236
237 let mut created_dirs = Vec::new();
238 for (key, _, _, _) in &targets {
239 if let Some(parent) = key.parent() {
240 if !parent.as_os_str().is_empty() {
241 let missing_dirs = missing_parent_dirs(parent);
242 if let Err(e) = std::fs::create_dir_all(parent) {
243 let mut dirs_to_remove = created_dirs;
244 dirs_to_remove.extend(missing_dirs);
245 let rollback_ok = rollback_created_dirs(&dirs_to_remove);
246 return Err(AftError::IoError {
247 path: parent.display().to_string(),
248 message: format!(
249 "{}; restore_last_operation aborted; partial_rollback: {}; rollback_succeeded: {}",
250 e,
251 !rollback_ok,
252 rollback_ok
253 ),
254 });
255 }
256 created_dirs.extend(missing_dirs);
257 }
258 }
259 }
260
261 let mut written = Vec::new();
262 for (key, entry, _, existing_content) in &targets {
263 if let Err(e) = std::fs::write(key, &entry.content) {
264 let files_rollback_ok =
265 rollback_transactional_restore(&written, Some((key, existing_content)));
266 let dirs_rollback_ok = rollback_created_dirs(&created_dirs);
267 let rollback_ok = files_rollback_ok && dirs_rollback_ok;
268 return Err(AftError::IoError {
269 path: key.display().to_string(),
270 message: format!(
271 "{}; restore_last_operation aborted; partial_rollback: {}; rollback_succeeded: {}",
272 e,
273 !rollback_ok,
274 rollback_ok
275 ),
276 });
277 }
278 written.push((key.clone(), existing_content.clone()));
279 }
280
281 let mut restored = Vec::new();
282 let mut warnings = Vec::new();
283 for (key, entry, warning, _) in targets {
284 self.commit_restored_backup(session, &key);
285 if let Some(warning) = warning {
286 warnings.push(format!("{}: {}", key.display(), warning));
287 }
288 restored.push(RestoredFile {
289 path: key,
290 backup_id: entry.backup_id,
291 });
292 }
293 self.touch_session(session);
294
295 Ok(RestoredOperation {
296 op_id,
297 restored,
298 warnings,
299 })
300 }
301
302 pub fn restore_latest(
305 &mut self,
306 session: &str,
307 path: &Path,
308 ) -> Result<(BackupEntry, Option<String>), AftError> {
309 let key = canonicalize_key(path);
310
311 let in_memory = self
313 .entries
314 .get(session)
315 .and_then(|s| s.get(&key))
316 .map_or(false, |s| !s.is_empty());
317 if in_memory {
318 let result = self.do_restore(session, &key, path);
319 if result.is_ok() {
320 self.touch_session(session);
321 }
322 return result;
323 }
324
325 if self.load_from_disk_if_needed(session, &key) {
327 let warning = self.check_external_modification(session, &key, path);
329 let (entry, _) = self.do_restore(session, &key, path)?;
330 self.touch_session(session);
331 return Ok((entry, warning));
332 }
333
334 Err(AftError::NoUndoHistory {
335 path: path.display().to_string(),
336 })
337 }
338
339 pub fn history(&self, session: &str, path: &Path) -> Vec<BackupEntry> {
341 let key = canonicalize_key(path);
342 self.entries
343 .get(session)
344 .and_then(|s| s.get(&key))
345 .cloned()
346 .unwrap_or_default()
347 }
348
349 pub fn disk_history_count(&self, session: &str, path: &Path) -> usize {
351 let key = canonicalize_key(path);
352 self.disk_index
353 .get(session)
354 .and_then(|s| s.get(&key))
355 .map(|m| m.count)
356 .unwrap_or(0)
357 }
358
359 pub fn tracked_files(&self, session: &str) -> Vec<PathBuf> {
362 let mut files: std::collections::HashSet<PathBuf> = self
363 .entries
364 .get(session)
365 .map(|s| s.keys().cloned().collect())
366 .unwrap_or_default();
367 if let Some(disk) = self.disk_index.get(session) {
368 for key in disk.keys() {
369 files.insert(key.clone());
370 }
371 }
372 files.into_iter().collect()
373 }
374
375 pub fn sessions_with_backups(&self) -> Vec<String> {
378 let mut sessions: std::collections::HashSet<String> =
379 self.entries.keys().cloned().collect();
380 for s in self.disk_index.keys() {
381 sessions.insert(s.clone());
382 }
383 sessions.into_iter().collect()
384 }
385
386 pub fn total_disk_bytes(&self) -> u64 {
389 let mut total = 0u64;
390 for session_dirs in self.disk_index.values() {
391 for meta in session_dirs.values() {
392 if let Ok(read_dir) = std::fs::read_dir(&meta.dir) {
393 for entry in read_dir.flatten() {
394 if let Ok(m) = entry.metadata() {
395 if m.is_file() {
396 total += m.len();
397 }
398 }
399 }
400 }
401 }
402 }
403 total
404 }
405
406 fn next_id(&self) -> String {
407 let n = self.counter.fetch_add(1, Ordering::Relaxed);
408 format!("backup-{}", n)
409 }
410
411 fn touch_session(&mut self, session: &str) {
412 let now = current_timestamp();
413 self.session_meta
414 .entry(session.to_string())
415 .or_default()
416 .last_accessed = now;
417 self.write_session_marker(session, now);
418 }
419
420 fn do_restore(
423 &mut self,
424 session: &str,
425 key: &Path,
426 path: &Path,
427 ) -> Result<(BackupEntry, Option<String>), AftError> {
428 let session_entries =
429 self.entries
430 .get_mut(session)
431 .ok_or_else(|| AftError::NoUndoHistory {
432 path: path.display().to_string(),
433 })?;
434 let stack = session_entries
435 .get_mut(key)
436 .ok_or_else(|| AftError::NoUndoHistory {
437 path: path.display().to_string(),
438 })?;
439
440 let entry = stack
441 .last()
442 .cloned()
443 .ok_or_else(|| AftError::NoUndoHistory {
444 path: path.display().to_string(),
445 })?;
446
447 if let Some(parent) = path.parent() {
451 if !parent.as_os_str().is_empty() {
452 std::fs::create_dir_all(parent).map_err(|e| AftError::IoError {
453 path: parent.display().to_string(),
454 message: e.to_string(),
455 })?;
456 }
457 }
458 std::fs::write(path, &entry.content).map_err(|e| AftError::IoError {
459 path: path.display().to_string(),
460 message: e.to_string(),
461 })?;
462
463 stack.pop();
464 if stack.is_empty() {
465 session_entries.remove(key);
466 if session_entries.is_empty() {
468 self.entries.remove(session);
469 }
470 self.remove_disk_backups(session, key);
471 } else {
472 let stack_clone = self
473 .entries
474 .get(session)
475 .and_then(|s| s.get(key))
476 .cloned()
477 .unwrap_or_default();
478 self.write_snapshot_to_disk(session, key, &stack_clone);
479 }
480
481 Ok((entry, None))
482 }
483
484 fn commit_restored_backup(&mut self, session: &str, key: &Path) {
485 let mut remove_key = false;
486 let mut remove_session = false;
487 let mut remaining_stack = None;
488
489 if let Some(session_entries) = self.entries.get_mut(session) {
490 if let Some(stack) = session_entries.get_mut(key) {
491 stack.pop();
492 if stack.is_empty() {
493 remove_key = true;
494 } else {
495 remaining_stack = Some(stack.clone());
496 }
497 }
498
499 if remove_key {
500 session_entries.remove(key);
501 remove_session = session_entries.is_empty();
502 }
503 }
504
505 if remove_session {
506 self.entries.remove(session);
507 }
508
509 if remove_key {
510 self.remove_disk_backups(session, key);
511 } else if let Some(stack) = remaining_stack {
512 self.write_snapshot_to_disk(session, key, &stack);
513 }
514 }
515
516 fn check_external_modification(
517 &self,
518 session: &str,
519 key: &Path,
520 path: &Path,
521 ) -> Option<String> {
522 if let (Some(stack), Ok(current)) = (
523 self.entries.get(session).and_then(|s| s.get(key)),
524 std::fs::read_to_string(path),
525 ) {
526 if let Some(latest) = stack.last() {
527 if latest.content != current {
528 return Some("file was modified externally since last backup".to_string());
529 }
530 }
531 }
532 None
533 }
534
535 fn backups_dir(&self) -> Option<PathBuf> {
538 self.storage_dir.as_ref().map(|d| d.join("backups"))
539 }
540
541 fn session_dir(&self, session: &str) -> Option<PathBuf> {
542 self.backups_dir()
543 .map(|d| d.join(Self::session_hash(session)))
544 }
545
546 fn session_hash(session: &str) -> String {
547 hash_session(session)
548 }
549
550 fn path_hash(key: &Path) -> String {
551 stable_hash_16(key.to_string_lossy().as_bytes())
556 }
557
558 fn write_session_marker(&self, session: &str, last_accessed: u64) {
559 let Some(session_dir) = self.session_dir(session) else {
560 return;
561 };
562 if let Err(e) = std::fs::create_dir_all(&session_dir) {
563 crate::slog_warn!("failed to create session dir: {}", e);
564 return;
565 }
566 let marker = session_dir.join("session.json");
567 let json = serde_json::json!({
568 "schema_version": SCHEMA_VERSION,
569 "session_id": session,
570 "last_accessed": last_accessed,
571 });
572 if let Ok(s) = serde_json::to_string_pretty(&json) {
573 let tmp = session_dir.join("session.json.tmp");
574 if std::fs::write(&tmp, s).is_ok() {
575 let _ = std::fs::rename(&tmp, marker);
576 }
577 }
578 }
579
580 fn gc_stale_sessions(&mut self, ttl_hours: u32) {
581 let backups_dir = match self.backups_dir() {
582 Some(d) if d.exists() => d,
583 _ => return,
584 };
585 let ttl_secs = u64::from(if ttl_hours == 0 { 72 } else { ttl_hours }) * 60 * 60;
586 let cutoff = current_timestamp().saturating_sub(ttl_secs);
587 let entries = match std::fs::read_dir(&backups_dir) {
588 Ok(entries) => entries,
589 Err(_) => return,
590 };
591
592 for entry in entries.flatten() {
593 let session_dir = entry.path();
594 if !session_dir.is_dir() || session_dir.join("meta.json").exists() {
595 continue;
596 }
597 let Some(last_accessed) = Self::read_session_last_accessed(&session_dir) else {
598 continue;
599 };
600 if last_accessed >= cutoff {
601 continue;
602 }
603 if let Err(e) = std::fs::remove_dir_all(&session_dir) {
604 crate::slog_warn!(
605 "failed to remove stale backup session {}: {}",
606 session_dir.display(),
607 e
608 );
609 } else {
610 crate::slog_warn!(
611 "removed stale backup session {} (last_accessed={})",
612 session_dir.display(),
613 last_accessed
614 );
615 }
616 }
617 }
618
619 fn migrate_legacy_layout_if_needed(&mut self) {
627 let backups_dir = match self.backups_dir() {
628 Some(d) if d.exists() => d,
629 _ => return,
630 };
631 let default_session_dir =
632 backups_dir.join(Self::session_hash(crate::protocol::DEFAULT_SESSION_ID));
633
634 let entries = match std::fs::read_dir(&backups_dir) {
635 Ok(e) => e,
636 Err(_) => return,
637 };
638 let mut migrated = 0usize;
639 for entry in entries.flatten() {
640 let entry_path = entry.path();
641 if !entry_path.is_dir() {
643 continue;
644 }
645 if entry_path == default_session_dir {
646 continue;
647 }
648 let meta_path = entry_path.join("meta.json");
649 if !meta_path.exists() {
650 continue; }
652 if let Err(e) = std::fs::create_dir_all(&default_session_dir) {
655 crate::slog_warn!("failed to create default session dir: {}", e);
656 return;
657 }
658 let leaf = match entry_path.file_name() {
659 Some(n) => n,
660 None => continue,
661 };
662 let target = default_session_dir.join(leaf);
663 if target.exists() {
664 continue;
667 }
668 match std::fs::rename(&entry_path, &target) {
669 Ok(()) => {
670 Self::upgrade_meta_file(
672 &target.join("meta.json"),
673 crate::protocol::DEFAULT_SESSION_ID,
674 );
675 migrated += 1;
676 }
677 Err(e) => {
678 crate::slog_warn!(
679 "failed to migrate legacy backup {}: {}",
680 entry_path.display(),
681 e
682 );
683 }
684 }
685 }
686 if migrated > 0 {
687 crate::slog_info!(
688 "migrated {} legacy backup entries into default session namespace",
689 migrated
690 );
691 let marker = default_session_dir.join("session.json");
693 let json = serde_json::json!({
694 "schema_version": SCHEMA_VERSION,
695 "session_id": crate::protocol::DEFAULT_SESSION_ID,
696 "last_accessed": current_timestamp(),
697 });
698 if let Ok(s) = serde_json::to_string_pretty(&json) {
699 let _ = std::fs::write(&marker, s);
700 }
701 }
702 }
703
704 fn upgrade_meta_file(meta_path: &Path, session_id: &str) {
705 let content = match std::fs::read_to_string(meta_path) {
706 Ok(c) => c,
707 Err(_) => return,
708 };
709 let mut parsed: serde_json::Value = match serde_json::from_str(&content) {
710 Ok(v) => v,
711 Err(_) => return,
712 };
713 if let Some(obj) = parsed.as_object_mut() {
714 let count = obj.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
715 obj.insert(
716 "schema_version".to_string(),
717 serde_json::json!(SCHEMA_VERSION),
718 );
719 obj.insert("session_id".to_string(), serde_json::json!(session_id));
720 obj.entry("entries").or_insert_with(|| {
721 serde_json::Value::Array(
722 (0..count)
723 .map(|i| {
724 serde_json::json!({
725 "backup_id": format!("disk-{}", i),
726 "timestamp": 0,
727 "description": "restored from disk",
728 "op_id": null,
729 })
730 })
731 .collect(),
732 )
733 });
734 }
735 if let Ok(s) = serde_json::to_string_pretty(&parsed) {
736 let tmp = meta_path.with_extension("json.tmp");
737 if std::fs::write(&tmp, &s).is_ok() {
738 let _ = std::fs::rename(&tmp, meta_path);
739 }
740 }
741 }
742
743 fn load_disk_index(&mut self) {
744 let backups_dir = match self.backups_dir() {
745 Some(d) if d.exists() => d,
746 _ => return,
747 };
748 let session_dirs = match std::fs::read_dir(&backups_dir) {
749 Ok(e) => e,
750 Err(_) => return,
751 };
752 let mut total_entries = 0usize;
753 for session_entry in session_dirs.flatten() {
754 let session_dir = session_entry.path();
755 if !session_dir.is_dir() {
756 continue;
757 }
758 let session_id = Self::read_session_marker(&session_dir)
761 .unwrap_or_else(|| crate::protocol::DEFAULT_SESSION_ID.to_string());
762
763 let path_dirs = match std::fs::read_dir(&session_dir) {
764 Ok(e) => e,
765 Err(_) => continue,
766 };
767 let per_session = self.disk_index.entry(session_id.clone()).or_default();
768 for path_entry in path_dirs.flatten() {
769 let path_dir = path_entry.path();
770 if !path_dir.is_dir() {
771 continue;
772 }
773 let meta_path = path_dir.join("meta.json");
774 if let Ok(content) = std::fs::read_to_string(&meta_path) {
775 if let Ok(meta) = serde_json::from_str::<serde_json::Value>(&content) {
776 if let (Some(path_str), Some(count)) = (
777 meta.get("path").and_then(|v| v.as_str()),
778 meta.get("count").and_then(|v| v.as_u64()),
779 ) {
780 per_session.insert(
781 PathBuf::from(path_str),
782 DiskMeta {
783 dir: path_dir.clone(),
784 count: count as usize,
785 },
786 );
787 total_entries += 1;
788 }
789 }
790 }
791 }
792 }
793 if total_entries > 0 {
794 crate::slog_info!(
795 "loaded {} backup entries across {} session(s) from disk",
796 total_entries,
797 self.disk_index.len()
798 );
799 }
800 }
801
802 fn read_session_marker(session_dir: &Path) -> Option<String> {
803 let marker = session_dir.join("session.json");
804 let content = std::fs::read_to_string(&marker).ok()?;
805 let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
806 parsed
807 .get("session_id")
808 .and_then(|v| v.as_str())
809 .map(|s| s.to_string())
810 }
811
812 fn read_session_last_accessed(session_dir: &Path) -> Option<u64> {
813 let marker = session_dir.join("session.json");
814 let content = std::fs::read_to_string(&marker).ok()?;
815 let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
816 parsed.get("last_accessed").and_then(|v| v.as_u64())
817 }
818
819 fn load_from_disk_if_needed(&mut self, session: &str, key: &Path) -> bool {
820 let meta = match self
821 .disk_index
822 .get(session)
823 .and_then(|s| s.get(key))
824 .cloned()
825 {
826 Some(m) if m.count > 0 => m,
827 _ => return false,
828 };
829
830 let mut entries = Vec::new();
831 let entry_meta = std::fs::read_to_string(meta.dir.join("meta.json"))
832 .ok()
833 .and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
834 .and_then(|meta| meta.get("entries").and_then(|v| v.as_array()).cloned())
835 .unwrap_or_default();
836
837 for i in 0..meta.count {
838 let bak_path = meta.dir.join(format!("{}.bak", i));
839 if let Ok(content) = std::fs::read_to_string(&bak_path) {
840 let meta = entry_meta.get(i);
841 entries.push(BackupEntry {
842 backup_id: meta
843 .and_then(|m| m.get("backup_id"))
844 .and_then(|v| v.as_str())
845 .map(str::to_string)
846 .unwrap_or_else(|| format!("disk-{}", i)),
847 content,
848 timestamp: meta
849 .and_then(|m| m.get("timestamp"))
850 .and_then(|v| v.as_u64())
851 .unwrap_or(0),
852 description: meta
853 .and_then(|m| m.get("description"))
854 .and_then(|v| v.as_str())
855 .unwrap_or("restored from disk")
856 .to_string(),
857 op_id: meta
858 .and_then(|m| m.get("op_id"))
859 .and_then(|v| v.as_str())
860 .map(str::to_string),
861 });
862 }
863 }
864
865 if entries.is_empty() {
866 return false;
867 }
868
869 self.entries
870 .entry(session.to_string())
871 .or_default()
872 .insert(key.to_path_buf(), entries);
873 true
874 }
875
876 fn write_snapshot_to_disk(&mut self, session: &str, key: &Path, stack: &[BackupEntry]) {
877 let session_dir = match self.session_dir(session) {
878 Some(d) => d,
879 None => return,
880 };
881
882 if let Err(e) = std::fs::create_dir_all(&session_dir) {
884 crate::slog_warn!("failed to create session dir: {}", e);
885 return;
886 }
887 let marker = session_dir.join("session.json");
888 if !marker.exists() {
889 let json = serde_json::json!({
890 "schema_version": SCHEMA_VERSION,
891 "session_id": session,
892 "last_accessed": current_timestamp(),
893 });
894 if let Ok(s) = serde_json::to_string_pretty(&json) {
895 let _ = std::fs::write(&marker, s);
896 }
897 }
898
899 let hash = Self::path_hash(key);
900 let dir = session_dir.join(&hash);
901 if let Err(e) = std::fs::create_dir_all(&dir) {
902 crate::slog_warn!("failed to create backup dir: {}", e);
903 return;
904 }
905
906 for (i, entry) in stack.iter().enumerate() {
907 let bak_path = dir.join(format!("{}.bak", i));
908 let tmp_path = dir.join(format!("{}.bak.tmp", i));
909 if std::fs::write(&tmp_path, &entry.content).is_ok() {
910 let _ = std::fs::rename(&tmp_path, &bak_path);
911 }
912 }
913
914 for i in stack.len()..MAX_UNDO_DEPTH {
916 let old = dir.join(format!("{}.bak", i));
917 if old.exists() {
918 let _ = std::fs::remove_file(&old);
919 }
920 }
921
922 let entries: Vec<serde_json::Value> = stack
923 .iter()
924 .map(|entry| {
925 serde_json::json!({
926 "backup_id": entry.backup_id,
927 "timestamp": entry.timestamp,
928 "description": entry.description,
929 "op_id": entry.op_id,
930 })
931 })
932 .collect();
933 let meta = serde_json::json!({
934 "schema_version": SCHEMA_VERSION,
935 "session_id": session,
936 "path": key.display().to_string(),
937 "count": stack.len(),
938 "entries": entries,
939 });
940 let meta_path = dir.join("meta.json");
941 let meta_tmp = dir.join("meta.json.tmp");
942 if let Ok(content) = serde_json::to_string_pretty(&meta) {
943 if std::fs::write(&meta_tmp, &content).is_ok() {
944 let _ = std::fs::rename(&meta_tmp, &meta_path);
945 }
946 }
947
948 self.disk_index
951 .entry(session.to_string())
952 .or_default()
953 .insert(
954 key.to_path_buf(),
955 DiskMeta {
956 dir,
957 count: stack.len(),
958 },
959 );
960 }
961
962 fn remove_disk_backups(&mut self, session: &str, key: &Path) {
963 let removed = self.disk_index.get_mut(session).and_then(|s| s.remove(key));
964 if let Some(meta) = removed {
965 let _ = std::fs::remove_dir_all(&meta.dir);
966 } else if let Some(session_dir) = self.session_dir(session) {
967 let hash = Self::path_hash(key);
968 let dir = session_dir.join(&hash);
969 if dir.exists() {
970 let _ = std::fs::remove_dir_all(&dir);
971 }
972 }
973
974 let empty = self
977 .disk_index
978 .get(session)
979 .map(|s| s.is_empty())
980 .unwrap_or(false);
981 if empty {
982 self.disk_index.remove(session);
983 }
984 }
985}
986
987pub fn hash_session(session: &str) -> String {
988 stable_hash_16(session.as_bytes())
989}
990
991pub fn new_op_id() -> String {
992 let mut bytes = [0u8; 4];
993 if getrandom::fill(&mut bytes).is_err() {
994 bytes = current_timestamp().to_le_bytes()[..4]
995 .try_into()
996 .unwrap_or([0; 4]);
997 }
998 let rand = u32::from_le_bytes(bytes);
999 format!("op-{}-{:08x}", current_timestamp() * 1000, rand)
1000}
1001
1002fn canonicalize_key(path: &Path) -> PathBuf {
1003 std::fs::canonicalize(path).unwrap_or_else(|err| {
1004 log::debug!(
1005 "backup canonicalize_key fallback for {}: {}",
1006 path.display(),
1007 err
1008 );
1009 path.to_path_buf()
1010 })
1011}
1012
1013fn rollback_transactional_restore(
1014 written: &[(PathBuf, Option<Vec<u8>>)],
1015 attempted: Option<(&PathBuf, &Option<Vec<u8>>)>,
1016) -> bool {
1017 let mut ok = true;
1018
1019 if let Some((path, content)) = attempted {
1020 ok &= rollback_one_restore_write(path, content);
1021 }
1022
1023 for (path, content) in written.iter().rev() {
1024 ok &= rollback_one_restore_write(path, content);
1025 }
1026
1027 ok
1028}
1029
1030fn rollback_one_restore_write(path: &Path, content: &Option<Vec<u8>>) -> bool {
1031 match content {
1032 Some(content) => std::fs::write(path, content).is_ok(),
1033 None => match std::fs::remove_file(path) {
1034 Ok(()) => true,
1035 Err(e) if e.kind() == std::io::ErrorKind::NotFound => true,
1036 Err(_) => false,
1037 },
1038 }
1039}
1040
1041fn missing_parent_dirs(parent: &Path) -> Vec<PathBuf> {
1042 let mut dirs = Vec::new();
1043 let mut current = Some(parent);
1044
1045 while let Some(dir) = current {
1046 if dir.as_os_str().is_empty() || dir.exists() {
1047 break;
1048 }
1049 dirs.push(dir.to_path_buf());
1050 current = dir.parent();
1051 }
1052
1053 dirs
1054}
1055
1056fn rollback_created_dirs(dirs: &[PathBuf]) -> bool {
1057 let mut dirs = dirs.to_vec();
1058 dirs.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
1059 dirs.dedup();
1060
1061 let mut ok = true;
1062 for dir in dirs {
1063 match std::fs::remove_dir(&dir) {
1064 Ok(()) => {}
1065 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1066 Err(_) => ok = false,
1067 }
1068 }
1069
1070 ok
1071}
1072
1073fn current_timestamp() -> u64 {
1074 std::time::SystemTime::now()
1075 .duration_since(std::time::UNIX_EPOCH)
1076 .unwrap_or_default()
1077 .as_secs()
1078}
1079
1080fn stable_hash_16(bytes: &[u8]) -> String {
1081 let digest = Sha256::digest(bytes);
1082 digest[..8]
1083 .iter()
1084 .map(|byte| format!("{:02x}", byte))
1085 .collect()
1086}
1087
1088fn backup_sequence(backup_id: &str) -> Option<u64> {
1089 backup_id
1090 .strip_prefix("backup-")
1091 .or_else(|| backup_id.strip_prefix("disk-"))
1092 .and_then(|s| s.parse().ok())
1093}
1094
1095#[cfg(test)]
1096mod tests {
1097 use super::*;
1098 use crate::protocol::DEFAULT_SESSION_ID;
1099 use std::fs;
1100 #[cfg(unix)]
1101 use std::os::unix::fs::PermissionsExt;
1102
1103 fn temp_file(name: &str, content: &str) -> PathBuf {
1104 let dir = std::env::temp_dir().join("aft_backup_tests");
1105 fs::create_dir_all(&dir).unwrap();
1106 let path = dir.join(name);
1107 fs::write(&path, content).unwrap();
1108 path
1109 }
1110
1111 #[test]
1112 fn snapshot_and_restore_round_trip() {
1113 let path = temp_file("round_trip.txt", "original");
1114 let mut store = BackupStore::new();
1115
1116 let id = store
1117 .snapshot(DEFAULT_SESSION_ID, &path, "before edit")
1118 .unwrap();
1119 assert!(id.starts_with("backup-"));
1120
1121 fs::write(&path, "modified").unwrap();
1122 assert_eq!(fs::read_to_string(&path).unwrap(), "modified");
1123
1124 let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
1125 assert_eq!(entry.content, "original");
1126 assert_eq!(fs::read_to_string(&path).unwrap(), "original");
1127 }
1128
1129 #[test]
1130 fn multiple_snapshots_preserve_order() {
1131 let path = temp_file("order.txt", "v1");
1132 let mut store = BackupStore::new();
1133
1134 store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
1135 fs::write(&path, "v2").unwrap();
1136 store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
1137 fs::write(&path, "v3").unwrap();
1138 store.snapshot(DEFAULT_SESSION_ID, &path, "third").unwrap();
1139
1140 let history = store.history(DEFAULT_SESSION_ID, &path);
1141 assert_eq!(history.len(), 3);
1142 assert_eq!(history[0].content, "v1");
1143 assert_eq!(history[1].content, "v2");
1144 assert_eq!(history[2].content, "v3");
1145 }
1146
1147 #[test]
1148 fn restore_pops_from_stack() {
1149 let path = temp_file("pop.txt", "v1");
1150 let mut store = BackupStore::new();
1151
1152 store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
1153 fs::write(&path, "v2").unwrap();
1154 store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
1155
1156 let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
1157 assert_eq!(entry.description, "second");
1158 assert_eq!(entry.content, "v2");
1159
1160 let history = store.history(DEFAULT_SESSION_ID, &path);
1161 assert_eq!(history.len(), 1);
1162 }
1163
1164 #[test]
1165 fn empty_history_returns_empty_vec() {
1166 let store = BackupStore::new();
1167 let path = Path::new("/tmp/aft_backup_tests/nonexistent_history.txt");
1168 assert!(store.history(DEFAULT_SESSION_ID, path).is_empty());
1169 }
1170
1171 #[test]
1172 fn snapshot_nonexistent_file_returns_error() {
1173 let mut store = BackupStore::new();
1174 let path = Path::new("/tmp/aft_backup_tests/absolutely_does_not_exist.txt");
1175 assert!(store.snapshot(DEFAULT_SESSION_ID, path, "test").is_err());
1176 }
1177
1178 #[test]
1179 fn tracked_files_lists_snapshotted_paths() {
1180 let path1 = temp_file("tracked1.txt", "a");
1181 let path2 = temp_file("tracked2.txt", "b");
1182 let mut store = BackupStore::new();
1183
1184 store.snapshot(DEFAULT_SESSION_ID, &path1, "snap1").unwrap();
1185 store.snapshot(DEFAULT_SESSION_ID, &path2, "snap2").unwrap();
1186 assert_eq!(store.tracked_files(DEFAULT_SESSION_ID).len(), 2);
1187 }
1188
1189 #[test]
1190 fn sessions_are_isolated() {
1191 let path = temp_file("isolated.txt", "original");
1192 let mut store = BackupStore::new();
1193
1194 store.snapshot("session_a", &path, "a's snapshot").unwrap();
1195
1196 assert!(store.history("session_b", &path).is_empty());
1198 assert_eq!(store.tracked_files("session_b").len(), 0);
1199
1200 let err = store.restore_latest("session_b", &path);
1202 assert!(matches!(err, Err(AftError::NoUndoHistory { .. })));
1203
1204 assert_eq!(store.history("session_a", &path).len(), 1);
1206 assert_eq!(store.tracked_files("session_a").len(), 1);
1207 }
1208
1209 #[test]
1210 fn per_session_per_file_cap_is_independent() {
1211 let path = temp_file("cap_indep.txt", "v0");
1214 let mut store = BackupStore::new();
1215
1216 for i in 0..(MAX_UNDO_DEPTH + 5) {
1217 fs::write(&path, format!("a{}", i)).unwrap();
1218 store.snapshot("session_a", &path, "a").unwrap();
1219 }
1220 fs::write(&path, "b_initial").unwrap();
1221 store.snapshot("session_b", &path, "b").unwrap();
1222
1223 assert_eq!(store.history("session_a", &path).len(), MAX_UNDO_DEPTH);
1225 assert_eq!(store.history("session_b", &path).len(), 1);
1227 }
1228
1229 #[test]
1230 fn sessions_with_backups_lists_all_namespaces() {
1231 let path_a = temp_file("sessions_list_a.txt", "a");
1232 let path_b = temp_file("sessions_list_b.txt", "b");
1233 let mut store = BackupStore::new();
1234
1235 store.snapshot("alice", &path_a, "from alice").unwrap();
1236 store.snapshot("bob", &path_b, "from bob").unwrap();
1237
1238 let sessions = store.sessions_with_backups();
1239 assert_eq!(sessions.len(), 2);
1240 assert!(sessions.iter().any(|s| s == "alice"));
1241 assert!(sessions.iter().any(|s| s == "bob"));
1242 }
1243
1244 #[test]
1245 fn disk_persistence_survives_reload() {
1246 let dir = std::env::temp_dir().join("aft_backup_disk_test");
1247 let _ = fs::remove_dir_all(&dir);
1248 fs::create_dir_all(&dir).unwrap();
1249
1250 let file_path = temp_file("disk_persist.txt", "original");
1251
1252 {
1254 let mut store = BackupStore::new();
1255 store.set_storage_dir(dir.clone(), 72);
1256 store
1257 .snapshot(DEFAULT_SESSION_ID, &file_path, "before edit")
1258 .unwrap();
1259 }
1260
1261 fs::write(&file_path, "externally modified").unwrap();
1263
1264 let mut store2 = BackupStore::new();
1266 store2.set_storage_dir(dir.clone(), 72);
1267
1268 let (entry, warning) = store2
1269 .restore_latest(DEFAULT_SESSION_ID, &file_path)
1270 .unwrap();
1271 assert_eq!(entry.content, "original");
1272 assert!(warning.is_some()); assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
1274
1275 let _ = fs::remove_dir_all(&dir);
1276 }
1277
1278 #[test]
1279 fn legacy_flat_layout_migrates_to_default_session() {
1280 let dir = std::env::temp_dir().join("aft_backup_migration_test");
1283 let _ = fs::remove_dir_all(&dir);
1284 fs::create_dir_all(&dir).unwrap();
1285 let backups = dir.join("backups");
1286 fs::create_dir_all(&backups).unwrap();
1287
1288 let legacy_hash = "deadbeefcafebabe";
1290 let legacy_dir = backups.join(legacy_hash);
1291 fs::create_dir_all(&legacy_dir).unwrap();
1292 fs::write(legacy_dir.join("0.bak"), "original content").unwrap();
1293 let legacy_meta = serde_json::json!({
1294 "path": "/tmp/migrated_file.txt",
1295 "count": 1,
1296 });
1297 fs::write(
1298 legacy_dir.join("meta.json"),
1299 serde_json::to_string_pretty(&legacy_meta).unwrap(),
1300 )
1301 .unwrap();
1302
1303 let mut store = BackupStore::new();
1305 store.set_storage_dir(dir.clone(), 72);
1306
1307 let default_session_dir = backups.join(BackupStore::session_hash(DEFAULT_SESSION_ID));
1310 assert!(default_session_dir.exists());
1311 assert!(default_session_dir.join(legacy_hash).exists());
1312 assert!(!backups.join(legacy_hash).exists());
1313
1314 let meta_content =
1316 fs::read_to_string(default_session_dir.join(legacy_hash).join("meta.json")).unwrap();
1317 let meta: serde_json::Value = serde_json::from_str(&meta_content).unwrap();
1318 assert_eq!(meta["session_id"], DEFAULT_SESSION_ID);
1319 assert_eq!(meta["schema_version"], SCHEMA_VERSION);
1320
1321 let _ = fs::remove_dir_all(&dir);
1322 }
1323
1324 #[test]
1325 fn set_storage_dir_removes_stale_backup_sessions() {
1326 let dir = std::env::temp_dir().join("aft_backup_gc_test");
1327 let _ = fs::remove_dir_all(&dir);
1328 let backups = dir.join("backups");
1329 fs::create_dir_all(&backups).unwrap();
1330
1331 let stale_session_dir = backups.join("stale-session");
1332 fs::create_dir_all(&stale_session_dir).unwrap();
1333 let stale_marker = serde_json::json!({
1334 "schema_version": SCHEMA_VERSION,
1335 "session_id": "stale",
1336 "last_accessed": 1,
1337 });
1338 fs::write(
1339 stale_session_dir.join("session.json"),
1340 serde_json::to_string_pretty(&stale_marker).unwrap(),
1341 )
1342 .unwrap();
1343
1344 let mut store = BackupStore::new();
1345 store.set_storage_dir(dir.clone(), 1);
1346
1347 assert!(!stale_session_dir.exists());
1348 let _ = fs::remove_dir_all(&dir);
1349 }
1350
1351 #[test]
1352 fn restore_last_operation_restores_all_top_entries_for_same_op() {
1353 let path_a = temp_file("op_restore_a.txt", "a1");
1354 let path_b = temp_file("op_restore_b.txt", "b1");
1355 let mut store = BackupStore::new();
1356 let op_id = "op-test-00000001";
1357
1358 store
1359 .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "a", Some(op_id))
1360 .unwrap();
1361 store
1362 .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "b", Some(op_id))
1363 .unwrap();
1364 fs::write(&path_a, "a2").unwrap();
1365 fs::write(&path_b, "b2").unwrap();
1366
1367 let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
1368 assert_eq!(restored.op_id, op_id);
1369 assert_eq!(restored.restored.len(), 2);
1370 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a1");
1371 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b1");
1372 }
1373
1374 #[cfg(unix)]
1379 #[test]
1380 fn restore_last_operation_is_atomic_when_a_write_fails() {
1381 let dir = std::env::temp_dir().join("aft_backup_tests_atomic_restore");
1382 let _ = fs::remove_dir_all(&dir);
1383 fs::create_dir_all(&dir).unwrap();
1384 let path_a = dir.join("a.txt");
1385 let path_b = dir.join("b.txt");
1386 let path_c = dir.join("c.txt");
1387 fs::write(&path_a, "a-original").unwrap();
1388 fs::write(&path_b, "b-original").unwrap();
1389 fs::write(&path_c, "c-original").unwrap();
1390
1391 let mut store = BackupStore::new();
1392 let op_id = "op-atomic-restore-01";
1393 let id_a = store
1394 .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "a", Some(op_id))
1395 .unwrap();
1396 let id_b = store
1397 .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "b", Some(op_id))
1398 .unwrap();
1399 let id_c = store
1400 .snapshot_with_op(DEFAULT_SESSION_ID, &path_c, "c", Some(op_id))
1401 .unwrap();
1402 fs::write(&path_a, "a-modified").unwrap();
1403 fs::write(&path_b, "b-modified").unwrap();
1404 fs::write(&path_c, "c-modified").unwrap();
1405
1406 let original_permissions = fs::metadata(&path_b).unwrap().permissions();
1407 let mut readonly_permissions = original_permissions.clone();
1408 readonly_permissions.set_mode(0o444);
1409 fs::set_permissions(&path_b, readonly_permissions).unwrap();
1410
1411 let result = store.restore_last_operation(DEFAULT_SESSION_ID);
1412 fs::set_permissions(&path_b, original_permissions).unwrap();
1413
1414 assert!(result.is_err());
1415 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-modified");
1416 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-modified");
1417 assert_eq!(fs::read_to_string(&path_c).unwrap(), "c-modified");
1418
1419 let history_a = store.history(DEFAULT_SESSION_ID, &path_a);
1420 let history_b = store.history(DEFAULT_SESSION_ID, &path_b);
1421 let history_c = store.history(DEFAULT_SESSION_ID, &path_c);
1422 assert_eq!(history_a.len(), 1);
1423 assert_eq!(history_b.len(), 1);
1424 assert_eq!(history_c.len(), 1);
1425 assert_eq!(history_a[0].backup_id, id_a);
1426 assert_eq!(history_b[0].backup_id, id_b);
1427 assert_eq!(history_c[0].backup_id, id_c);
1428 assert_eq!(history_a[0].op_id.as_deref(), Some(op_id));
1429 assert_eq!(history_b[0].op_id.as_deref(), Some(op_id));
1430 assert_eq!(history_c[0].op_id.as_deref(), Some(op_id));
1431
1432 let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
1433 assert_eq!(restored.op_id, op_id);
1434 assert_eq!(restored.restored.len(), 3);
1435 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-original");
1436 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-original");
1437 assert_eq!(fs::read_to_string(&path_c).unwrap(), "c-original");
1438
1439 let _ = fs::remove_dir_all(&dir);
1440 }
1441
1442 #[test]
1443 fn restore_last_operation_restores_only_most_recent_op() {
1444 let path_a = temp_file("op_recent_a.txt", "a1");
1445 let path_b = temp_file("op_recent_b.txt", "b1");
1446 let mut store = BackupStore::new();
1447
1448 store
1449 .snapshot_with_op(DEFAULT_SESSION_ID, &path_a, "older", Some("op-older"))
1450 .unwrap();
1451 store
1452 .snapshot_with_op(DEFAULT_SESSION_ID, &path_b, "newer", Some("op-newer"))
1453 .unwrap();
1454 fs::write(&path_a, "a2").unwrap();
1455 fs::write(&path_b, "b2").unwrap();
1456
1457 let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
1458 assert_eq!(restored.op_id, "op-newer");
1459 assert_eq!(restored.restored.len(), 1);
1460 assert_eq!(fs::read_to_string(&path_a).unwrap(), "a2");
1461 assert_eq!(fs::read_to_string(&path_b).unwrap(), "b1");
1462 }
1463
1464 #[test]
1465 fn restore_recreates_missing_parent_directories() {
1466 let dir = std::env::temp_dir().join("aft_backup_tests_recreate_parents");
1469 let _ = fs::remove_dir_all(&dir);
1470 let nested = dir.join("nested");
1471 fs::create_dir_all(&nested).unwrap();
1472 let path = nested.join("inner.txt");
1473 fs::write(&path, "original").unwrap();
1474
1475 let mut store = BackupStore::new();
1476 let op_id = "op-recreate-parents-01";
1477 store
1478 .snapshot_with_op(DEFAULT_SESSION_ID, &path, "original", Some(op_id))
1479 .unwrap();
1480
1481 fs::remove_dir_all(&dir).unwrap();
1483 assert!(!path.exists());
1484 assert!(!nested.exists());
1485 assert!(!dir.exists());
1486
1487 let restored = store.restore_last_operation(DEFAULT_SESSION_ID).unwrap();
1488 assert_eq!(restored.op_id, op_id);
1489 assert_eq!(restored.restored.len(), 1);
1490 assert!(
1491 path.exists(),
1492 "file should be restored even though both nested/ and dir/ were missing"
1493 );
1494 assert_eq!(fs::read_to_string(&path).unwrap(), "original");
1495
1496 let _ = fs::remove_dir_all(&dir);
1497 }
1498
1499 #[test]
1500 fn restore_last_operation_ignores_legacy_entries_without_op_id() {
1501 let path = temp_file("op_legacy_none.txt", "v1");
1502 let mut store = BackupStore::new();
1503
1504 store.snapshot(DEFAULT_SESSION_ID, &path, "legacy").unwrap();
1505 fs::write(&path, "v2").unwrap();
1506
1507 let err = store.restore_last_operation(DEFAULT_SESSION_ID);
1508 assert!(matches!(err, Err(AftError::NoUndoHistory { .. })));
1509 assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
1510 }
1511
1512 #[test]
1513 fn schema_v2_meta_loads_with_none_op_id_and_persists_as_v3() {
1514 let dir = std::env::temp_dir().join("aft_backup_v2_to_v3_test");
1515 let _ = fs::remove_dir_all(&dir);
1516 fs::create_dir_all(&dir).unwrap();
1517 let file_path = temp_file("v2_to_v3.txt", "original");
1518 let key = canonicalize_key(&file_path);
1519 let session_dir = dir
1520 .join("backups")
1521 .join(BackupStore::session_hash(DEFAULT_SESSION_ID));
1522 let path_dir = session_dir.join(BackupStore::path_hash(&key));
1523 fs::create_dir_all(&path_dir).unwrap();
1524 fs::write(path_dir.join("0.bak"), "original").unwrap();
1525 fs::write(
1526 session_dir.join("session.json"),
1527 serde_json::to_string_pretty(&serde_json::json!({
1528 "schema_version": 2,
1529 "session_id": DEFAULT_SESSION_ID,
1530 "last_accessed": current_timestamp(),
1531 }))
1532 .unwrap(),
1533 )
1534 .unwrap();
1535 fs::write(
1536 path_dir.join("meta.json"),
1537 serde_json::to_string_pretty(&serde_json::json!({
1538 "schema_version": 2,
1539 "session_id": DEFAULT_SESSION_ID,
1540 "path": key.display().to_string(),
1541 "count": 1,
1542 }))
1543 .unwrap(),
1544 )
1545 .unwrap();
1546
1547 let mut store = BackupStore::new();
1548 store.set_storage_dir(dir.clone(), 72);
1549 assert!(store.load_from_disk_if_needed(DEFAULT_SESSION_ID, &key));
1550 let history = store.history(DEFAULT_SESSION_ID, &file_path);
1551 assert_eq!(history.len(), 1);
1552 assert_eq!(history[0].op_id, None);
1553
1554 fs::write(&file_path, "second").unwrap();
1555 store
1556 .snapshot_with_op(DEFAULT_SESSION_ID, &file_path, "second", Some("op-v3"))
1557 .unwrap();
1558 let written: serde_json::Value =
1559 serde_json::from_str(&fs::read_to_string(path_dir.join("meta.json")).unwrap()).unwrap();
1560 assert_eq!(written["schema_version"], SCHEMA_VERSION);
1561 assert_eq!(written["entries"][0]["op_id"], serde_json::Value::Null);
1562 assert_eq!(written["entries"][1]["op_id"], "op-v3");
1563 let _ = fs::remove_dir_all(&dir);
1564 }
1565
1566 #[test]
1567 fn per_file_restore_latest_still_works_with_op_ids() {
1568 let path = temp_file("op_per_file.txt", "v1");
1569 let mut store = BackupStore::new();
1570
1571 store
1572 .snapshot_with_op(DEFAULT_SESSION_ID, &path, "op", Some("op-file"))
1573 .unwrap();
1574 fs::write(&path, "v2").unwrap();
1575
1576 let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
1577 assert_eq!(entry.op_id.as_deref(), Some("op-file"));
1578 assert_eq!(fs::read_to_string(&path).unwrap(), "v1");
1579 }
1580}