1mod storage;
53pub mod types;
54
55pub use storage::{RecoveryScope, RecoveryStorage};
56pub use types::{
57 generate_buffer_id, path_hash, ChunkMeta, ChunkedRecoveryData, ChunkedRecoveryIndex,
58 InplaceWriteRecovery, RecoveryChunk, RecoveryEntry, RecoveryMetadata, RecoveryResult,
59 SessionInfo, MAX_CHUNK_SIZE,
60};
61
62use std::collections::HashMap;
63use std::io;
64use std::path::{Path, PathBuf};
65use std::time::Instant;
66
67#[derive(Debug, Clone)]
69pub struct RecoveryConfig {
70 pub enabled: bool,
72 pub max_recovery_age_secs: u64,
74}
75
76impl Default for RecoveryConfig {
77 fn default() -> Self {
78 Self {
79 enabled: true,
80 max_recovery_age_secs: 7 * 24 * 60 * 60, }
82 }
83}
84
85#[derive(Debug)]
90pub struct RecoveryService {
91 storage: RecoveryStorage,
93 config: RecoveryConfig,
95 last_save_times: HashMap<String, Instant>,
97 session_started: bool,
99}
100
101impl RecoveryService {
102 pub fn new() -> io::Result<Self> {
104 Ok(Self {
105 storage: RecoveryStorage::new()?,
106 config: RecoveryConfig::default(),
107 last_save_times: HashMap::new(),
108 session_started: false,
109 })
110 }
111
112 pub fn with_config(config: RecoveryConfig) -> io::Result<Self> {
114 Ok(Self {
115 storage: RecoveryStorage::new()?,
116 config,
117 last_save_times: HashMap::new(),
118 session_started: false,
119 })
120 }
121
122 pub fn with_storage_dir(storage_dir: PathBuf) -> Self {
125 Self {
126 storage: RecoveryStorage::with_dir(storage_dir),
127 config: RecoveryConfig::default(),
128 last_save_times: HashMap::new(),
129 session_started: false,
130 }
131 }
132
133 pub fn with_config_and_dir(config: RecoveryConfig, storage_dir: PathBuf) -> Self {
135 Self {
136 storage: RecoveryStorage::with_dir(storage_dir),
137 config,
138 last_save_times: HashMap::new(),
139 session_started: false,
140 }
141 }
142
143 pub fn with_scope(
147 config: RecoveryConfig,
148 base_recovery_dir: &Path,
149 scope: &RecoveryScope,
150 ) -> Self {
151 if let Err(e) = RecoveryStorage::migrate_flat_layout(base_recovery_dir, scope) {
153 tracing::warn!("Failed to migrate recovery files: {}", e);
154 }
155 Self {
156 storage: RecoveryStorage::with_scope(base_recovery_dir, scope),
157 config,
158 last_save_times: HashMap::new(),
159 session_started: false,
160 }
161 }
162
163 pub fn is_enabled(&self) -> bool {
165 self.config.enabled
166 }
167
168 pub fn storage(&self) -> &RecoveryStorage {
170 &self.storage
171 }
172
173 pub fn should_offer_recovery(&self) -> io::Result<bool> {
179 if !self.config.enabled {
180 return Ok(false);
181 }
182
183 if self.storage.detect_crash()? {
185 let entries = self.storage.list_entries()?;
187 return Ok(!entries.is_empty());
188 }
189
190 Ok(false)
191 }
192
193 pub fn start_session(&mut self) -> io::Result<()> {
195 if !self.config.enabled {
196 return Ok(());
197 }
198
199 self.storage.create_session_lock()?;
200 self.session_started = true;
201 tracing::info!("Recovery session started");
202 Ok(())
203 }
204
205 pub fn end_session_preserving(&mut self, preserve_ids: &[String]) -> io::Result<()> {
210 if !self.config.enabled || !self.session_started {
211 return Ok(());
212 }
213
214 if preserve_ids.is_empty() {
215 let cleaned = self.storage.cleanup_all()?;
217 tracing::info!("Cleaned up {} recovery files", cleaned);
218 } else {
219 let entries = self.storage.list_entries()?;
221 let mut cleaned = 0;
222 for entry in entries {
223 if !preserve_ids.contains(&entry.id)
224 && self.storage.delete_recovery(&entry.id).is_ok()
225 {
226 cleaned += 1;
227 }
228 }
229 tracing::info!(
230 "Cleaned up {} recovery files, preserved {} unnamed buffer(s)",
231 cleaned,
232 preserve_ids.len()
233 );
234 }
235
236 self.storage.remove_session_lock()?;
238 self.session_started = false;
239 tracing::info!("Recovery session ended");
240 Ok(())
241 }
242
243 pub fn end_session(&mut self) -> io::Result<()> {
245 self.end_session_preserving(&[])
246 }
247
248 pub fn heartbeat(&self) -> io::Result<()> {
250 if self.config.enabled && self.session_started {
251 self.storage.update_session_lock()?;
252 }
253 Ok(())
254 }
255
256 pub fn needs_auto_recovery_save(&self, _buffer_id: &str, recovery_pending: bool) -> bool {
265 if !self.config.enabled {
266 return false;
267 }
268
269 recovery_pending
271 }
272
273 pub fn get_buffer_id(&self, path: Option<&Path>) -> String {
275 self.storage.get_buffer_id(path)
276 }
277
278 #[allow(clippy::too_many_arguments)]
299 pub fn save_buffer(
300 &mut self,
301 buffer_id: &str,
302 chunks: Vec<RecoveryChunk>,
303 original_path: Option<&Path>,
304 buffer_name: Option<&str>,
305 line_count: Option<usize>,
306 original_file_size: usize,
307 final_size: usize,
308 ) -> io::Result<()> {
309 if !self.config.enabled {
310 return Ok(());
311 }
312
313 self.storage.save_recovery(
314 buffer_id,
315 chunks,
316 original_path,
317 buffer_name,
318 line_count,
319 original_file_size,
320 final_size,
321 )?;
322 self.last_save_times
323 .insert(buffer_id.to_string(), Instant::now());
324
325 tracing::trace!(
326 "Saved recovery for buffer {} (original: {} bytes, final: {} bytes)",
327 buffer_id,
328 original_file_size,
329 final_size
330 );
331 Ok(())
332 }
333
334 pub fn delete_buffer_recovery(&mut self, buffer_id: &str) -> io::Result<()> {
336 if !self.config.enabled {
337 return Ok(());
338 }
339
340 self.storage.delete_recovery(buffer_id)?;
341 self.last_save_times.remove(buffer_id);
342
343 tracing::debug!("Deleted recovery for buffer {}", buffer_id);
344 Ok(())
345 }
346
347 pub fn list_recoverable(&self) -> io::Result<Vec<RecoveryEntry>> {
349 self.storage.list_entries()
350 }
351
352 pub fn load_recovery(&self, entry: &RecoveryEntry) -> io::Result<RecoveryResult> {
358 if entry.metadata.original_file_size > 0 {
360 if let Some(ref original_path) = entry.metadata.original_path {
362 if entry.original_file_modified() {
364 return Ok(RecoveryResult::OriginalFileModified {
365 id: entry.id.clone(),
366 original_path: original_path.clone(),
367 });
368 }
369
370 if !original_path.exists() {
371 return Ok(RecoveryResult::Corrupted {
372 id: entry.id.clone(),
373 reason: format!(
374 "Original file not found: {}. Recovery requires the original file.",
375 original_path.display()
376 ),
377 });
378 }
379
380 let chunked_data =
382 self.storage
383 .read_chunked_content(&entry.id)?
384 .ok_or_else(|| {
385 io::Error::new(io::ErrorKind::NotFound, "Chunk content not found")
386 })?;
387
388 return Ok(RecoveryResult::RecoveredChunks {
389 original_path: original_path.clone(),
390 chunks: chunked_data.chunks,
391 });
392 } else {
393 return Ok(RecoveryResult::Corrupted {
394 id: entry.id.clone(),
395 reason: "Recovery entry requires original file but path is not set".to_string(),
396 });
397 }
398 }
399
400 if entry.metadata.original_path.is_some() && entry.original_file_modified() {
403 return Ok(RecoveryResult::OriginalFileModified {
404 id: entry.id.clone(),
405 original_path: entry.metadata.original_path.clone().unwrap(),
406 });
407 }
408
409 let chunked_data = self
411 .storage
412 .read_chunked_content(&entry.id)?
413 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Chunk content not found"))?;
414
415 if chunked_data.chunks.len() == 1 && chunked_data.chunks[0].offset == 0 {
417 Ok(RecoveryResult::Recovered {
418 original_path: entry.metadata.original_path.clone(),
419 content: chunked_data.chunks[0].content.clone(),
420 })
421 } else {
422 Ok(RecoveryResult::Corrupted {
423 id: entry.id.clone(),
424 reason: "Invalid recovery format: expected single chunk for new buffer".to_string(),
425 })
426 }
427 }
428
429 pub fn load_recovery_with_original(
433 &self,
434 entry: &RecoveryEntry,
435 original_file: &Path,
436 ) -> io::Result<RecoveryResult> {
437 let content = self
438 .storage
439 .reconstruct_from_chunks(&entry.id, original_file)?;
440 Ok(RecoveryResult::Recovered {
441 original_path: Some(original_file.to_path_buf()),
442 content,
443 })
444 }
445
446 pub fn accept_recovery(&mut self, entry: &RecoveryEntry) -> io::Result<RecoveryResult> {
448 let result = self.load_recovery(entry)?;
449 if matches!(result, RecoveryResult::Recovered { .. }) {
451 self.storage.delete_recovery(&entry.id)?;
452 }
453 Ok(result)
454 }
455
456 pub fn discard_recovery(&mut self, entry: &RecoveryEntry) -> io::Result<()> {
458 self.storage.delete_recovery(&entry.id)
459 }
460
461 pub fn discard_all_recovery(&mut self) -> io::Result<usize> {
463 self.storage.cleanup_all()
464 }
465
466 pub fn cleanup_old(&self) -> io::Result<usize> {
472 if !self.config.enabled {
473 return Ok(0);
474 }
475
476 let entries = self.storage.list_entries()?;
477 let mut cleaned = 0;
478
479 for entry in entries {
480 if entry.age_seconds() > self.config.max_recovery_age_secs
481 && self.storage.delete_recovery(&entry.id).is_ok()
482 {
483 cleaned += 1;
484 }
485 }
486
487 if cleaned > 0 {
488 tracing::info!("Cleaned up {} old recovery files", cleaned);
489 }
490
491 Ok(cleaned)
492 }
493
494 pub fn cleanup_orphans(&self) -> io::Result<usize> {
496 self.storage.cleanup_orphans()
497 }
498}
499
500impl Default for RecoveryService {
501 fn default() -> Self {
502 Self::new().unwrap_or_else(|_| Self {
503 storage: RecoveryStorage::default(),
504 config: RecoveryConfig::default(),
505 last_save_times: HashMap::new(),
506 session_started: false,
507 })
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 use tempfile::TempDir;
515
516 fn create_test_service() -> (RecoveryService, TempDir) {
517 let temp_dir = TempDir::new().unwrap();
518 let storage = RecoveryStorage::with_dir(temp_dir.path().to_path_buf());
519 let service = RecoveryService {
520 storage,
521 config: RecoveryConfig::default(),
522 last_save_times: HashMap::new(),
523 session_started: false,
524 };
525 (service, temp_dir)
526 }
527
528 #[test]
529 fn test_session_lifecycle() {
530 let (mut service, _temp) = create_test_service();
531
532 service.start_session().unwrap();
534 assert!(service.session_started);
535
536 service.end_session().unwrap();
538 assert!(!service.session_started);
539 }
540
541 #[test]
542 fn test_save_and_recover() {
543 let (mut service, _temp) = create_test_service();
544 service.start_session().unwrap();
545
546 let content = b"Test content for recovery";
547 let path = Path::new("/test/file.txt");
548 let id = service.get_buffer_id(Some(path));
549
550 let chunks = vec![RecoveryChunk::new(0, 0, content.to_vec())];
552 service
553 .save_buffer(&id, chunks, Some(path), None, Some(1), 0, content.len())
554 .unwrap();
555
556 let entries = service.list_recoverable().unwrap();
558 assert_eq!(entries.len(), 1);
559
560 let entry = &entries[0];
562 let result = service.load_recovery(entry).unwrap();
563 match result {
564 RecoveryResult::Recovered {
565 original_path,
566 content: loaded,
567 } => {
568 assert_eq!(original_path, Some(path.to_path_buf()));
569 assert_eq!(loaded, content);
570 }
571 _ => panic!("Expected Recovered result"),
572 }
573 }
574
575 #[test]
576 fn test_needs_auto_recovery_save() {
577 let (service, _temp) = create_test_service();
578 let id = "test-buffer";
579
580 assert!(!service.needs_auto_recovery_save(id, false));
582
583 assert!(service.needs_auto_recovery_save(id, true));
585
586 assert!(!service.needs_auto_recovery_save(id, false));
588 }
589
590 #[test]
591 fn test_disabled_service() {
592 let (mut service, _temp) = create_test_service();
593 service.config.enabled = false;
594
595 assert!(!service.needs_auto_recovery_save("test", true));
597
598 let chunks = vec![RecoveryChunk::new(0, 0, b"content".to_vec())];
600 service
601 .save_buffer("test", chunks, None, None, None, 0, 7)
602 .unwrap();
603 }
604
605 #[test]
606 fn test_load_recovery_returns_chunks_for_large_files() {
607 use std::fs;
608
609 let (mut service, temp_dir) = create_test_service();
610 service.start_session().unwrap();
611
612 let original_content = b"Hello, this is the original content!";
614 let original_path = temp_dir.path().join("original.txt");
615 fs::write(&original_path, original_content).unwrap();
616
617 let id = service.get_buffer_id(Some(&original_path));
618
619 let chunks = vec![RecoveryChunk::new(0, 0, b"PREFIX: ".to_vec())];
622 service
623 .save_buffer(
624 &id,
625 chunks,
626 Some(&original_path),
627 None,
628 Some(1),
629 original_content.len(), original_content.len() + 8,
631 )
632 .unwrap();
633
634 let entries = service.list_recoverable().unwrap();
636 assert_eq!(entries.len(), 1);
637
638 let result = service.load_recovery(&entries[0]).unwrap();
639 match result {
640 RecoveryResult::RecoveredChunks {
641 original_path: path,
642 chunks,
643 } => {
644 assert_eq!(path, original_path);
645 assert_eq!(chunks.len(), 1);
646 assert_eq!(chunks[0].offset, 0);
647 assert_eq!(chunks[0].original_len, 0);
648 assert_eq!(chunks[0].content, b"PREFIX: ");
649 }
650 _ => panic!("Expected RecoveredChunks result, got {:?}", result),
651 }
652 }
653}