ryo-storage 0.1.0

Persistent storage and transaction log for RYO
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
//! Auto-saving transaction logger.
//!
//! Wraps `TxLogger` with automatic persistence based on `TxLogMode`.

use crate::storage::{Format, RyoStorage, StateRef, StorageResult, TxLogMode};
use crate::txlog::{MutationRecord, TxAction, TxLog, TxLogger};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};

/// Transaction logger with automatic persistence.
///
/// Wraps a `TxLogger` and automatically saves to `~/.ryo/` based on the
/// configured `TxLogMode`.
///
/// # Example
///
/// ```ignore
/// use ryo_core::storage::{AutoSaveLogger, TxLogMode};
///
/// // Create with auto-save on commit
/// let logger = AutoSaveLogger::new("/my/project", 100, TxLogMode::OnCommit)?;
///
/// // Log as usual
/// logger.log_mutation("Rename", "foo -> bar", 5);
///
/// // Trigger save (e.g., called from CodingWorld::commit_changes)
/// logger.on_commit()?;
///
/// // Finish and get the log
/// let log = logger.finish()?;
/// ```
pub struct AutoSaveLogger {
    /// Underlying async logger
    inner: TxLogger,
    /// Auto-save mode
    mode: TxLogMode,
    /// Storage format
    format: Format,
    /// Storage reference (lazy-initialized)
    storage: Arc<Mutex<Option<RyoStorage>>>,
    /// Track if we've persisted already
    persisted: bool,
    /// Session ID for this logger
    session_id: Option<String>,
}

impl AutoSaveLogger {
    /// Create a new auto-save logger.
    pub fn new(
        project_path: impl Into<PathBuf>,
        file_count: usize,
        mode: TxLogMode,
    ) -> StorageResult<Self> {
        Self::with_format(project_path, file_count, mode, Format::default())
    }

    /// Create with a specific storage format.
    pub fn with_format(
        project_path: impl Into<PathBuf>,
        file_count: usize,
        mode: TxLogMode,
        format: Format,
    ) -> StorageResult<Self> {
        let inner = TxLogger::start(project_path, file_count);

        Ok(Self {
            inner,
            mode,
            format,
            storage: Arc::new(Mutex::new(None)),
            persisted: false,
            session_id: None,
        })
    }

    /// Create with explicit storage (for testing or custom paths).
    pub fn with_storage(
        project_path: impl Into<PathBuf>,
        file_count: usize,
        mode: TxLogMode,
        storage: RyoStorage,
    ) -> Self {
        let inner = TxLogger::start(project_path, file_count);

        Self {
            inner,
            mode,
            format: Format::default(),
            storage: Arc::new(Mutex::new(Some(storage))),
            persisted: false,
            session_id: None,
        }
    }

    /// Get the storage format.
    pub fn format(&self) -> Format {
        self.format
    }

    /// Get the current mode.
    pub fn mode(&self) -> TxLogMode {
        self.mode
    }

    /// Set the mode (can be changed at runtime).
    pub fn set_mode(&mut self, mode: TxLogMode) {
        self.mode = mode;
    }

    // =========================================================================
    // Delegated logging methods
    // =========================================================================

    /// Log an action.
    pub fn log(&self, action: TxAction) {
        self.inner.log(action);
    }

    /// Log a goal being set.
    pub fn log_goal(&self, query: &str, intent_type: &str, confidence: f64) {
        self.inner.log_goal(query, intent_type, confidence);
    }

    /// Log a mutation being applied (legacy API - NOT replayable).
    ///
    /// **Note**: For replayable logging, use `record_mutation()` instead.
    /// If mode is `OnMutation`, this will trigger a persist.
    pub fn log_mutation(
        &mut self,
        mutation_type: &str,
        target: &str,
        changes: usize,
    ) -> StorageResult<()> {
        self.inner.log_mutation(mutation_type, target, changes);

        if self.mode.persist_on_mutation() {
            self.persist()?;
        }

        Ok(())
    }

    /// Record a mutation with automatic serialization (REPLAYABLE).
    ///
    /// This is the preferred method for logging mutations.
    /// If mode is `OnMutation`, this will trigger a persist.
    pub fn record_mutation<M>(&mut self, mutation: &M, changes: usize) -> StorageResult<()>
    where
        M: ryo_mutations::Mutation + ryo_mutations::ToSerializable,
    {
        self.inner.record_mutation(mutation, changes);

        if self.mode.persist_on_mutation() {
            self.persist()?;
        }

        Ok(())
    }

    /// Record a mutation with file path (REPLAYABLE).
    ///
    /// If mode is `OnMutation`, this will trigger a persist.
    pub fn record_mutation_for_file<M>(
        &mut self,
        mutation: &M,
        changes: usize,
        file_path: impl AsRef<Path>,
    ) -> StorageResult<()>
    where
        M: ryo_mutations::Mutation + ryo_mutations::ToSerializable,
    {
        self.inner
            .record_mutation_for_file(mutation, changes, file_path);

        if self.mode.persist_on_mutation() {
            self.persist()?;
        }

        Ok(())
    }

    /// Record a mutation with full state tracking (REPLAYABLE + VERIFIABLE).
    ///
    /// If mode is `OnMutation`, this will trigger a persist.
    pub fn record_mutation_tracked<M>(
        &mut self,
        mutation: &M,
        changes: usize,
        file_path: impl AsRef<Path>,
        pre_state: StateRef,
        post_state: StateRef,
    ) -> StorageResult<()>
    where
        M: ryo_mutations::Mutation + ryo_mutations::ToSerializable,
    {
        self.inner
            .record_mutation_tracked(mutation, changes, file_path, pre_state, post_state);

        if self.mode.persist_on_mutation() {
            self.persist()?;
        }

        Ok(())
    }

    /// Log a mutation with data.
    pub fn log_mutation_with_data(
        &mut self,
        mutation_type: &str,
        target: &str,
        changes: usize,
        data: serde_json::Value,
    ) -> StorageResult<()> {
        self.inner
            .log_mutation_with_data(mutation_type, target, changes, data);

        if self.mode.persist_on_mutation() {
            self.persist()?;
        }

        Ok(())
    }

    /// Log a batch of mutations.
    pub fn log_mutation_batch(
        &mut self,
        mutations: Vec<MutationRecord>,
        total_changes: usize,
    ) -> StorageResult<()> {
        self.inner.log_mutation_batch(mutations, total_changes);

        if self.mode.persist_on_mutation() {
            self.persist()?;
        }

        Ok(())
    }

    /// Log file loaded.
    pub fn log_file_loaded(&self, path: &Path, size_bytes: usize) {
        self.inner.log_file_loaded(path, size_bytes);
    }

    /// Log file modified.
    pub fn log_file_modified(&self, path: &Path, changes: usize) {
        self.inner.log_file_modified(path, changes);
    }

    /// Log file written.
    pub fn log_file_written(&self, path: &Path) {
        self.inner.log_file_written(path);
    }

    /// Log compile check result.
    pub fn log_compile_check(&self, success: bool, errors: Vec<String>) {
        self.inner.log_compile_check(success, errors);
    }

    /// Create a checkpoint.
    pub fn checkpoint(&self, name: &str) {
        self.inner.checkpoint(name);
    }

    /// Log an undo operation.
    pub fn log_undo(&self, target_id: u64) {
        self.inner.log_undo(target_id);
    }

    /// Log a redo operation.
    pub fn log_redo(&self, target_id: u64) {
        self.inner.log_redo(target_id);
    }

    /// Log a custom action.
    pub fn log_custom(&self, name: &str, data: serde_json::Value) {
        self.inner.log_custom(name, data);
    }

    // =========================================================================
    // Persistence triggers
    // =========================================================================

    /// Called when commit_changes() is invoked.
    ///
    /// If mode is `OnCommit`, this will trigger a persist.
    pub fn on_commit(&mut self) -> StorageResult<()> {
        if self.mode.persist_on_commit() {
            self.persist()?;
        }
        Ok(())
    }

    /// Explicitly persist the current log to storage.
    ///
    /// This can be called at any time, regardless of mode.
    /// Uses incremental save if session already has an ID.
    pub fn persist(&mut self) -> StorageResult<String> {
        if !self.mode.should_persist() && self.session_id.is_none() {
            // Mode is Off or Memory and we haven't started persisting
            // Skip actual persistence but return a placeholder
            return Ok(String::from("not-persisted"));
        }

        // For now, we can't get the log without finishing.
        // In a more sophisticated implementation, we'd need to:
        // 1. Send a "snapshot" message to the background thread
        // 2. Wait for it to return the current log state
        //
        // For simplicity, we'll mark that persistence is needed
        // and do the actual persistence in finish().
        self.persisted = false;

        Ok(self
            .session_id
            .clone()
            .unwrap_or_else(|| "pending".to_string()))
    }

    /// Finish logging and persist the final log.
    ///
    /// Returns the complete log and the session ID if persisted.
    pub fn finish(self) -> StorageResult<(TxLog, Option<String>)> {
        let should_persist = self.mode.should_persist();
        let storage_arc = Arc::clone(&self.storage);
        let format = self.format;

        // Now consume inner to get the log
        let log = self.inner.finish();

        // Persist if needed
        let session_id = if should_persist {
            let mut guard = storage_arc.lock().expect("autosave storage mutex poisoned");
            if guard.is_none() {
                let storage = RyoStorage::global()?.with_format(format);
                storage.ensure_init()?;
                *guard = Some(storage);
            }
            let storage = guard
                .as_mut()
                .expect("Some(storage) ensured by the is_none() init above");
            let id = storage.dump(&log)?;
            Some(id)
        } else {
            None
        };

        Ok((log, session_id))
    }

    /// Finish and return just the log (for compatibility).
    pub fn finish_log(self) -> TxLog {
        self.inner.finish()
    }

    /// Get elapsed time since session start.
    pub fn elapsed_ms(&self) -> u64 {
        self.inner.elapsed_ms()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn test_autosave_off_mode() {
        let logger = AutoSaveLogger::new("/test/project", 10, TxLogMode::Off).unwrap();

        logger.log_goal("test", "test", 0.9);

        let (log, session_id) = logger.finish().unwrap();
        assert!(!log.is_empty());
        assert!(session_id.is_none());
    }

    #[test]
    fn test_autosave_memory_mode() {
        let logger = AutoSaveLogger::new("/test/project", 10, TxLogMode::Memory).unwrap();

        logger.log_goal("test", "test", 0.9);

        let (log, session_id) = logger.finish().unwrap();
        assert!(!log.is_empty());
        assert!(session_id.is_none());
    }

    #[test]
    fn test_autosave_on_commit_mode() {
        let temp = TempDir::new().unwrap();
        let storage = RyoStorage::new(temp.path().join(".ryo")).unwrap();

        let mut logger =
            AutoSaveLogger::with_storage("/test/project", 10, TxLogMode::OnCommit, storage);

        logger.log_goal("test", "test", 0.9);
        logger.on_commit().unwrap();

        let (log, session_id) = logger.finish().unwrap();
        assert!(!log.is_empty());
        assert!(session_id.is_some());
    }

    #[test]
    fn test_autosave_on_mutation_mode() {
        let temp = TempDir::new().unwrap();
        let storage = RyoStorage::new(temp.path().join(".ryo")).unwrap();

        let mut logger =
            AutoSaveLogger::with_storage("/test/project", 10, TxLogMode::OnMutation, storage);

        logger.log_mutation("Rename", "foo -> bar", 3).unwrap();

        let (log, session_id) = logger.finish().unwrap();
        assert!(!log.is_empty());
        assert!(session_id.is_some());
    }
}