Skip to main content

fresh/services/recovery/
mod.rs

1//! File Recovery Service
2//!
3//! This module provides Emacs-style file recovery for the Fresh editor.
4//! It automatically saves buffer contents periodically and can recover
5//! them if the editor crashes.
6//!
7//! ## How it works
8//!
9//! 1. **Session Lock**: On startup, creates a lock file with the process ID
10//! 2. **Auto-Save**: Periodically saves modified buffers to recovery directory
11//! 3. **Crash Detection**: On startup, checks if lock file exists without running process
12//! 4. **Recovery**: If crash detected, offers to recover unsaved changes
13//!
14//! ## File Layout
15//!
16//! ```text
17//! ~/.local/share/fresh/recovery/
18//! ├── session.lock           # Session info (PID, start time)
19//! ├── {hash}.meta.json       # Recovery metadata with chunk index
20//! ├── {hash}.chunk.0         # Chunk 0 binary content
21//! ├── {hash}.chunk.1         # Chunk 1 binary content
22//! └── ...
23//! ```
24//!
25//! ## Storage Format
26//!
27//! All recovery data uses a chunked format:
28//! - For small files/new buffers: single chunk containing full content
29//! - For large files: only modified regions stored as chunks
30//!
31//! ## Usage
32//!
33//! ```rust,ignore
34//! use fresh::services::recovery::RecoveryService;
35//!
36//! // On startup
37//! let mut recovery = RecoveryService::new()?;
38//! if recovery.should_offer_recovery()? {
39//!     let entries = recovery.list_recoverable()?;
40//!     // Show recovery prompt to user
41//! }
42//! recovery.start_session()?;
43//!
44//! // During editing (call periodically)
45//! // For small files/new buffers:
46//! recovery.save_buffer("id", chunks, Some(&path), None, Some(10), 0, content_len)?;
47//!
48//! // On clean shutdown
49//! recovery.end_session()?;
50//! ```
51
52mod 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/// Configuration for the recovery service
68#[derive(Debug, Clone)]
69pub struct RecoveryConfig {
70    /// Whether recovery is enabled
71    pub enabled: bool,
72    /// Maximum age of recovery files before cleanup (in seconds)
73    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, // 7 days
81        }
82    }
83}
84
85/// The main recovery service
86///
87/// This is the high-level interface for the recovery system.
88/// It manages the session lock and coordinates buffer recovery.
89#[derive(Debug)]
90pub struct RecoveryService {
91    /// Storage backend
92    storage: RecoveryStorage,
93    /// Configuration
94    config: RecoveryConfig,
95    /// Last auto-recovery-save time per buffer
96    last_save_times: HashMap<String, Instant>,
97    /// Session started flag
98    session_started: bool,
99}
100
101impl RecoveryService {
102    /// Create a new recovery service
103    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    /// Create a new recovery service with custom config
113    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    /// Create a new recovery service with a custom storage directory
123    /// This is useful for testing with isolated temporary directories
124    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    /// Create a new recovery service with custom config and storage directory
134    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    /// Create a new recovery service scoped to a session or working directory.
144    ///
145    /// Performs one-time migration of old flat-layout recovery files if needed.
146    pub fn with_scope(
147        config: RecoveryConfig,
148        base_recovery_dir: &Path,
149        scope: &RecoveryScope,
150    ) -> Self {
151        // Attempt migration of old flat layout
152        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    /// Check if recovery is enabled
164    pub fn is_enabled(&self) -> bool {
165        self.config.enabled
166    }
167
168    /// Get the storage backend
169    pub fn storage(&self) -> &RecoveryStorage {
170        &self.storage
171    }
172
173    // ========================================================================
174    // Session management
175    // ========================================================================
176
177    /// Check if we should offer recovery (crash detected)
178    pub fn should_offer_recovery(&self) -> io::Result<bool> {
179        if !self.config.enabled {
180            return Ok(false);
181        }
182
183        // Check for crash
184        if self.storage.detect_crash()? {
185            // Also check if there are any recovery files
186            let entries = self.storage.list_entries()?;
187            return Ok(!entries.is_empty());
188        }
189
190        Ok(false)
191    }
192
193    /// Start a new session (call on editor startup after recovery handling)
194    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    /// End the session cleanly (call on normal editor shutdown)
206    ///
207    /// When `preserve_ids` is provided, recovery files matching those IDs
208    /// are kept (used to persist unnamed buffer contents across sessions).
209    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            // No IDs to preserve - clean up everything (original behavior)
216            let cleaned = self.storage.cleanup_all()?;
217            tracing::info!("Cleaned up {} recovery files", cleaned);
218        } else {
219            // Selectively clean up, preserving unnamed buffer recovery files
220            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        // Remove session lock
237        self.storage.remove_session_lock()?;
238        self.session_started = false;
239        tracing::info!("Recovery session ended");
240        Ok(())
241    }
242
243    /// End the session cleanly (call on normal editor shutdown)
244    pub fn end_session(&mut self) -> io::Result<()> {
245        self.end_session_preserving(&[])
246    }
247
248    /// Update session heartbeat (call periodically)
249    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    // ========================================================================
257    // Buffer tracking
258    // ========================================================================
259
260    /// Check if a buffer needs auto-recovery-save
261    ///
262    /// Returns true if recovery_pending is true. The recovery_pending flag is now
263    /// tracked on the buffer itself (TextBuffer.recovery_pending) rather than in this service.
264    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        // Must have pending recovery changes to need auto-save
270        recovery_pending
271    }
272
273    /// Get buffer ID for a path
274    pub fn get_buffer_id(&self, path: Option<&Path>) -> String {
275        self.storage.get_buffer_id(path)
276    }
277
278    // ========================================================================
279    // Recovery operations
280    // ========================================================================
281
282    /// Save a buffer's content for recovery
283    ///
284    /// All recovery uses the chunked format:
285    /// - For small files/new buffers: pass a single chunk containing full content
286    ///   with offset=0, original_len=0, original_file_size=0
287    /// - For large files: pass only the modified chunks with their offsets
288    ///
289    /// ## Parameters
290    ///
291    /// - `buffer_id`: Unique identifier for the buffer
292    /// - `chunks`: The content chunks to save
293    /// - `original_path`: Path to the original file (None for new buffers)
294    /// - `buffer_name`: Display name for the buffer
295    /// - `line_count`: Number of lines in the buffer
296    /// - `original_file_size`: Size of the original file (0 for new buffers)
297    /// - `final_size`: Total size after applying all modifications
298    #[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    /// Delete recovery for a buffer (call when buffer is saved normally or closed)
335    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    /// List all recoverable entries
348    pub fn list_recoverable(&self) -> io::Result<Vec<RecoveryEntry>> {
349        self.storage.list_entries()
350    }
351
352    /// Load recovery content for a specific entry
353    ///
354    /// For entries with original_file_size > 0, returns RecoveredChunks so the caller
355    /// can apply chunks directly to the buffer (more efficient than full reconstruction).
356    /// For new buffer entries (original_file_size == 0), the full content is in the chunks.
357    pub fn load_recovery(&self, entry: &RecoveryEntry) -> io::Result<RecoveryResult> {
358        // Check if we need the original file for reconstruction
359        if entry.metadata.original_file_size > 0 {
360            // Large file recovery - return chunks to apply on top of original
361            if let Some(ref original_path) = entry.metadata.original_path {
362                // Check if original file was modified since recovery was saved
363                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                // Load chunks and return them for direct application
381                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        // New buffer or small file - chunk contains full content
401        // For file-backed small files, check if the original was modified on disk
402        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        // Load the chunk data directly
410        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        // For original_file_size == 0, we expect exactly one chunk with offset=0
416        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    /// Load recovery with a provided original file path
430    ///
431    /// Use this when the original file has moved or you want to specify a different source.
432    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    /// Accept recovery for an entry (load and delete recovery file)
447    pub fn accept_recovery(&mut self, entry: &RecoveryEntry) -> io::Result<RecoveryResult> {
448        let result = self.load_recovery(entry)?;
449        // Delete the recovery file after successful load
450        if matches!(result, RecoveryResult::Recovered { .. }) {
451            self.storage.delete_recovery(&entry.id)?;
452        }
453        Ok(result)
454    }
455
456    /// Discard recovery for an entry
457    pub fn discard_recovery(&mut self, entry: &RecoveryEntry) -> io::Result<()> {
458        self.storage.delete_recovery(&entry.id)
459    }
460
461    /// Discard all recovery files
462    pub fn discard_all_recovery(&mut self) -> io::Result<usize> {
463        self.storage.cleanup_all()
464    }
465
466    // ========================================================================
467    // Maintenance
468    // ========================================================================
469
470    /// Clean up old recovery files (older than max_recovery_age_secs)
471    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    /// Clean up orphaned files
495    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        // Start session
533        service.start_session().unwrap();
534        assert!(service.session_started);
535
536        // End session
537        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        // Save recovery - create a single chunk with full content (new buffer style)
551        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        // List recoverable
557        let entries = service.list_recoverable().unwrap();
558        assert_eq!(entries.len(), 1);
559
560        // Load recovery
561        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        // Not recovery_pending - doesn't need save
581        assert!(!service.needs_auto_recovery_save(id, false));
582
583        // recovery_pending=true - needs save (instant)
584        assert!(service.needs_auto_recovery_save(id, true));
585
586        // After save, if recovery_pending becomes false, it doesn't need save
587        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        // needs_auto_recovery_save returns false when disabled
596        assert!(!service.needs_auto_recovery_save("test", true));
597
598        // save_buffer doesn't error when disabled
599        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        // Create an original file
613        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        // Save recovery with original_file_size > 0 (simulating large file recovery)
620        // Chunk: insert "PREFIX: " at offset 0
621        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_file_size > 0
630                original_content.len() + 8,
631            )
632            .unwrap();
633
634        // Load recovery - should return RecoveredChunks, not Recovered
635        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}