Skip to main content

ryo_storage/storage/
autosave.rs

1//! Auto-saving transaction logger.
2//!
3//! Wraps `TxLogger` with automatic persistence based on `TxLogMode`.
4
5use crate::storage::{Format, RyoStorage, StateRef, StorageResult, TxLogMode};
6use crate::txlog::{MutationRecord, TxAction, TxLog, TxLogger};
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, Mutex};
9
10/// Transaction logger with automatic persistence.
11///
12/// Wraps a `TxLogger` and automatically saves to `~/.ryo/` based on the
13/// configured `TxLogMode`.
14///
15/// # Example
16///
17/// ```ignore
18/// use ryo_core::storage::{AutoSaveLogger, TxLogMode};
19///
20/// // Create with auto-save on commit
21/// let logger = AutoSaveLogger::new("/my/project", 100, TxLogMode::OnCommit)?;
22///
23/// // Log as usual
24/// logger.log_mutation("Rename", "foo -> bar", 5);
25///
26/// // Trigger save (e.g., called from CodingWorld::commit_changes)
27/// logger.on_commit()?;
28///
29/// // Finish and get the log
30/// let log = logger.finish()?;
31/// ```
32pub struct AutoSaveLogger {
33    /// Underlying async logger
34    inner: TxLogger,
35    /// Auto-save mode
36    mode: TxLogMode,
37    /// Storage format
38    format: Format,
39    /// Storage reference (lazy-initialized)
40    storage: Arc<Mutex<Option<RyoStorage>>>,
41    /// Track if we've persisted already
42    persisted: bool,
43    /// Session ID for this logger
44    session_id: Option<String>,
45}
46
47impl AutoSaveLogger {
48    /// Create a new auto-save logger.
49    pub fn new(
50        project_path: impl Into<PathBuf>,
51        file_count: usize,
52        mode: TxLogMode,
53    ) -> StorageResult<Self> {
54        Self::with_format(project_path, file_count, mode, Format::default())
55    }
56
57    /// Create with a specific storage format.
58    pub fn with_format(
59        project_path: impl Into<PathBuf>,
60        file_count: usize,
61        mode: TxLogMode,
62        format: Format,
63    ) -> StorageResult<Self> {
64        let inner = TxLogger::start(project_path, file_count);
65
66        Ok(Self {
67            inner,
68            mode,
69            format,
70            storage: Arc::new(Mutex::new(None)),
71            persisted: false,
72            session_id: None,
73        })
74    }
75
76    /// Create with explicit storage (for testing or custom paths).
77    pub fn with_storage(
78        project_path: impl Into<PathBuf>,
79        file_count: usize,
80        mode: TxLogMode,
81        storage: RyoStorage,
82    ) -> Self {
83        let inner = TxLogger::start(project_path, file_count);
84
85        Self {
86            inner,
87            mode,
88            format: Format::default(),
89            storage: Arc::new(Mutex::new(Some(storage))),
90            persisted: false,
91            session_id: None,
92        }
93    }
94
95    /// Get the storage format.
96    pub fn format(&self) -> Format {
97        self.format
98    }
99
100    /// Get the current mode.
101    pub fn mode(&self) -> TxLogMode {
102        self.mode
103    }
104
105    /// Set the mode (can be changed at runtime).
106    pub fn set_mode(&mut self, mode: TxLogMode) {
107        self.mode = mode;
108    }
109
110    // =========================================================================
111    // Delegated logging methods
112    // =========================================================================
113
114    /// Log an action.
115    pub fn log(&self, action: TxAction) {
116        self.inner.log(action);
117    }
118
119    /// Log a goal being set.
120    pub fn log_goal(&self, query: &str, intent_type: &str, confidence: f64) {
121        self.inner.log_goal(query, intent_type, confidence);
122    }
123
124    /// Log a mutation being applied (legacy API - NOT replayable).
125    ///
126    /// **Note**: For replayable logging, use `record_mutation()` instead.
127    /// If mode is `OnMutation`, this will trigger a persist.
128    pub fn log_mutation(
129        &mut self,
130        mutation_type: &str,
131        target: &str,
132        changes: usize,
133    ) -> StorageResult<()> {
134        self.inner.log_mutation(mutation_type, target, changes);
135
136        if self.mode.persist_on_mutation() {
137            self.persist()?;
138        }
139
140        Ok(())
141    }
142
143    /// Record a mutation with automatic serialization (REPLAYABLE).
144    ///
145    /// This is the preferred method for logging mutations.
146    /// If mode is `OnMutation`, this will trigger a persist.
147    pub fn record_mutation<M>(&mut self, mutation: &M, changes: usize) -> StorageResult<()>
148    where
149        M: ryo_mutations::Mutation + ryo_mutations::ToSerializable,
150    {
151        self.inner.record_mutation(mutation, changes);
152
153        if self.mode.persist_on_mutation() {
154            self.persist()?;
155        }
156
157        Ok(())
158    }
159
160    /// Record a mutation with file path (REPLAYABLE).
161    ///
162    /// If mode is `OnMutation`, this will trigger a persist.
163    pub fn record_mutation_for_file<M>(
164        &mut self,
165        mutation: &M,
166        changes: usize,
167        file_path: impl AsRef<Path>,
168    ) -> StorageResult<()>
169    where
170        M: ryo_mutations::Mutation + ryo_mutations::ToSerializable,
171    {
172        self.inner
173            .record_mutation_for_file(mutation, changes, file_path);
174
175        if self.mode.persist_on_mutation() {
176            self.persist()?;
177        }
178
179        Ok(())
180    }
181
182    /// Record a mutation with full state tracking (REPLAYABLE + VERIFIABLE).
183    ///
184    /// If mode is `OnMutation`, this will trigger a persist.
185    pub fn record_mutation_tracked<M>(
186        &mut self,
187        mutation: &M,
188        changes: usize,
189        file_path: impl AsRef<Path>,
190        pre_state: StateRef,
191        post_state: StateRef,
192    ) -> StorageResult<()>
193    where
194        M: ryo_mutations::Mutation + ryo_mutations::ToSerializable,
195    {
196        self.inner
197            .record_mutation_tracked(mutation, changes, file_path, pre_state, post_state);
198
199        if self.mode.persist_on_mutation() {
200            self.persist()?;
201        }
202
203        Ok(())
204    }
205
206    /// Log a mutation with data.
207    pub fn log_mutation_with_data(
208        &mut self,
209        mutation_type: &str,
210        target: &str,
211        changes: usize,
212        data: serde_json::Value,
213    ) -> StorageResult<()> {
214        self.inner
215            .log_mutation_with_data(mutation_type, target, changes, data);
216
217        if self.mode.persist_on_mutation() {
218            self.persist()?;
219        }
220
221        Ok(())
222    }
223
224    /// Log a batch of mutations.
225    pub fn log_mutation_batch(
226        &mut self,
227        mutations: Vec<MutationRecord>,
228        total_changes: usize,
229    ) -> StorageResult<()> {
230        self.inner.log_mutation_batch(mutations, total_changes);
231
232        if self.mode.persist_on_mutation() {
233            self.persist()?;
234        }
235
236        Ok(())
237    }
238
239    /// Log file loaded.
240    pub fn log_file_loaded(&self, path: &Path, size_bytes: usize) {
241        self.inner.log_file_loaded(path, size_bytes);
242    }
243
244    /// Log file modified.
245    pub fn log_file_modified(&self, path: &Path, changes: usize) {
246        self.inner.log_file_modified(path, changes);
247    }
248
249    /// Log file written.
250    pub fn log_file_written(&self, path: &Path) {
251        self.inner.log_file_written(path);
252    }
253
254    /// Log compile check result.
255    pub fn log_compile_check(&self, success: bool, errors: Vec<String>) {
256        self.inner.log_compile_check(success, errors);
257    }
258
259    /// Create a checkpoint.
260    pub fn checkpoint(&self, name: &str) {
261        self.inner.checkpoint(name);
262    }
263
264    /// Log an undo operation.
265    pub fn log_undo(&self, target_id: u64) {
266        self.inner.log_undo(target_id);
267    }
268
269    /// Log a redo operation.
270    pub fn log_redo(&self, target_id: u64) {
271        self.inner.log_redo(target_id);
272    }
273
274    /// Log a custom action.
275    pub fn log_custom(&self, name: &str, data: serde_json::Value) {
276        self.inner.log_custom(name, data);
277    }
278
279    // =========================================================================
280    // Persistence triggers
281    // =========================================================================
282
283    /// Called when commit_changes() is invoked.
284    ///
285    /// If mode is `OnCommit`, this will trigger a persist.
286    pub fn on_commit(&mut self) -> StorageResult<()> {
287        if self.mode.persist_on_commit() {
288            self.persist()?;
289        }
290        Ok(())
291    }
292
293    /// Explicitly persist the current log to storage.
294    ///
295    /// This can be called at any time, regardless of mode.
296    /// Uses incremental save if session already has an ID.
297    pub fn persist(&mut self) -> StorageResult<String> {
298        if !self.mode.should_persist() && self.session_id.is_none() {
299            // Mode is Off or Memory and we haven't started persisting
300            // Skip actual persistence but return a placeholder
301            return Ok(String::from("not-persisted"));
302        }
303
304        // For now, we can't get the log without finishing.
305        // In a more sophisticated implementation, we'd need to:
306        // 1. Send a "snapshot" message to the background thread
307        // 2. Wait for it to return the current log state
308        //
309        // For simplicity, we'll mark that persistence is needed
310        // and do the actual persistence in finish().
311        self.persisted = false;
312
313        Ok(self
314            .session_id
315            .clone()
316            .unwrap_or_else(|| "pending".to_string()))
317    }
318
319    /// Finish logging and persist the final log.
320    ///
321    /// Returns the complete log and the session ID if persisted.
322    pub fn finish(self) -> StorageResult<(TxLog, Option<String>)> {
323        let should_persist = self.mode.should_persist();
324        let storage_arc = Arc::clone(&self.storage);
325        let format = self.format;
326
327        // Now consume inner to get the log
328        let log = self.inner.finish();
329
330        // Persist if needed
331        let session_id = if should_persist {
332            let mut guard = storage_arc.lock().expect("autosave storage mutex poisoned");
333            if guard.is_none() {
334                let storage = RyoStorage::global()?.with_format(format);
335                storage.ensure_init()?;
336                *guard = Some(storage);
337            }
338            let storage = guard
339                .as_mut()
340                .expect("Some(storage) ensured by the is_none() init above");
341            let id = storage.dump(&log)?;
342            Some(id)
343        } else {
344            None
345        };
346
347        Ok((log, session_id))
348    }
349
350    /// Finish and return just the log (for compatibility).
351    pub fn finish_log(self) -> TxLog {
352        self.inner.finish()
353    }
354
355    /// Get elapsed time since session start.
356    pub fn elapsed_ms(&self) -> u64 {
357        self.inner.elapsed_ms()
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use tempfile::TempDir;
365
366    #[test]
367    fn test_autosave_off_mode() {
368        let logger = AutoSaveLogger::new("/test/project", 10, TxLogMode::Off).unwrap();
369
370        logger.log_goal("test", "test", 0.9);
371
372        let (log, session_id) = logger.finish().unwrap();
373        assert!(!log.is_empty());
374        assert!(session_id.is_none());
375    }
376
377    #[test]
378    fn test_autosave_memory_mode() {
379        let logger = AutoSaveLogger::new("/test/project", 10, TxLogMode::Memory).unwrap();
380
381        logger.log_goal("test", "test", 0.9);
382
383        let (log, session_id) = logger.finish().unwrap();
384        assert!(!log.is_empty());
385        assert!(session_id.is_none());
386    }
387
388    #[test]
389    fn test_autosave_on_commit_mode() {
390        let temp = TempDir::new().unwrap();
391        let storage = RyoStorage::new(temp.path().join(".ryo")).unwrap();
392
393        let mut logger =
394            AutoSaveLogger::with_storage("/test/project", 10, TxLogMode::OnCommit, storage);
395
396        logger.log_goal("test", "test", 0.9);
397        logger.on_commit().unwrap();
398
399        let (log, session_id) = logger.finish().unwrap();
400        assert!(!log.is_empty());
401        assert!(session_id.is_some());
402    }
403
404    #[test]
405    fn test_autosave_on_mutation_mode() {
406        let temp = TempDir::new().unwrap();
407        let storage = RyoStorage::new(temp.path().join(".ryo")).unwrap();
408
409        let mut logger =
410            AutoSaveLogger::with_storage("/test/project", 10, TxLogMode::OnMutation, storage);
411
412        logger.log_mutation("Rename", "foo -> bar", 3).unwrap();
413
414        let (log, session_id) = logger.finish().unwrap();
415        assert!(!log.is_empty());
416        assert!(session_id.is_some());
417    }
418}