ryo-storage 0.1.0

Persistent storage and transaction log for RYO
Documentation
//! TxEntry and TxAction types for transaction logging.
//!
//! Each entry represents a single action that can be replayed.
//!
//! # Replay Support
//!
//! The [`TxAction::MutationApplied`] variant now includes optional `pre_state`
//! and `post_state` fields for determinism verification during replay.
//! See [`crate::storage`] module documentation for the full architecture.

use crate::storage::StateRef;
use ryo_analysis::SymbolPath;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

/// A single transaction log entry
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TxEntry {
    /// Unique ID within the session
    pub id: u64,

    /// Milliseconds since session start
    pub timestamp_ms: u64,

    /// The action performed
    pub action: TxAction,

    /// Execution duration in microseconds (if measured)
    pub duration_us: Option<u64>,
}

impl TxEntry {
    /// Create a new entry with the given action
    pub fn new(id: u64, timestamp_ms: u64, action: TxAction) -> Self {
        Self {
            id,
            timestamp_ms,
            action,
            duration_us: None,
        }
    }

    /// Set the execution duration
    pub fn with_duration(mut self, duration_us: u64) -> Self {
        self.duration_us = Some(duration_us);
        self
    }
}

/// Actions that can be logged and replayed
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum TxAction {
    // =========================================================================
    // Session Lifecycle
    // =========================================================================
    /// Session started
    SessionStart {
        /// Root path of the project being analyzed.
        project_path: PathBuf,
        /// Number of source files loaded at session start.
        file_count: usize,
    },

    /// Session ended
    SessionEnd {
        /// Total number of logical changes recorded in the session.
        total_changes: usize,
        /// Number of distinct files that were modified during the session.
        files_modified: usize,
    },

    // =========================================================================
    // Goal/Intent (high-level user intent)
    // =========================================================================
    /// A goal was set from user query
    GoalSet {
        /// Raw user query text that introduced the goal.
        query: String,
        /// Classifier output for the goal (e.g. `"RenameIdent"`).
        intent_type: String,
        /// Classifier confidence in `[0.0, 1.0]`.
        confidence: f64,
    },

    // =========================================================================
    // Mutation Operations (replayable actions)
    // =========================================================================
    /// A mutation was applied.
    ///
    /// This variant supports determinism verification via optional `pre_state`
    /// and `post_state` fields. During replay:
    /// - `pre_state`: Verify current state matches before applying
    /// - `post_state`: Verify result matches after applying
    MutationApplied {
        /// Type of mutation (e.g., "Rename", "AddField")
        mutation_type: String,
        /// Target of the mutation (symbol name, file path, etc.)
        target: String,
        /// Number of changes made
        changes: usize,
        /// Serialized mutation data for replay (optional)
        mutation_data: Option<serde_json::Value>,
        /// File path this mutation was applied to (for multi-file support)
        #[serde(default, skip_serializing_if = "Option::is_none")]
        file_path: Option<PathBuf>,
        /// State reference before mutation (for verification)
        #[serde(default, skip_serializing_if = "Option::is_none")]
        pre_state: Option<StateRef>,
        /// State reference after mutation (for verification)
        #[serde(default, skip_serializing_if = "Option::is_none")]
        post_state: Option<StateRef>,
        /// Symbols affected by this mutation (for history tracking)
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
        affected_symbols: Vec<SymbolPath>,
    },

    /// Batch of mutations applied atomically
    MutationBatch {
        /// Individual mutations contained in this batch.
        mutations: Vec<MutationRecord>,
        /// Sum of `changes` across all batch entries.
        total_changes: usize,
    },

    // =========================================================================
    // File Operations
    // =========================================================================
    /// File was loaded into memory
    FileLoaded {
        /// Path of the file that was loaded.
        path: PathBuf,
        /// On-disk size of the file in bytes at load time.
        size_bytes: usize,
    },

    /// File was modified in memory
    FileModified {
        /// Path of the file that was modified.
        path: PathBuf,
        /// Number of logical changes applied to the file.
        changes: usize,
    },

    /// File was written to disk
    FileWritten {
        /// Path of the file that was flushed to disk.
        path: PathBuf,
    },

    // =========================================================================
    // Verification
    // =========================================================================
    /// Compile check was performed
    CompileCheck {
        /// `true` iff the compile check completed with no errors.
        success: bool,
        /// Number of error diagnostics emitted.
        error_count: usize,
        /// Rendered error messages (may be truncated by the caller).
        errors: Vec<String>,
    },

    // =========================================================================
    // Undo/Redo markers
    // =========================================================================
    /// Checkpoint for undo (can revert to this point)
    Checkpoint {
        /// Human-readable label identifying this checkpoint.
        name: String,
    },

    /// Undo was performed
    Undo {
        /// ID of the entry that was undone
        target_id: u64,
    },

    /// Redo was performed
    Redo {
        /// ID of the entry that was redone
        target_id: u64,
    },

    // =========================================================================
    // Custom/Extension
    // =========================================================================
    /// Custom action for extensions
    Custom {
        /// Custom action identifier defined by the extension.
        name: String,
        /// Opaque JSON payload interpreted by the extension.
        data: serde_json::Value,
    },
}

/// Record of a single mutation within a batch
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MutationRecord {
    /// Mutation classifier (e.g. `"Rename"`, `"AddField"`).
    pub mutation_type: String,
    /// Mutation target identifier (symbol path, file path, ...).
    pub target: String,
    /// Number of logical changes produced by this mutation.
    pub changes: usize,
    /// File the mutation was applied to, if scoped to a single file.
    pub file_path: Option<PathBuf>,
    /// Symbols affected by this mutation (for history tracking)
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub affected_symbols: Vec<SymbolPath>,
}

impl TxAction {
    /// Get a short description of the action
    pub fn describe(&self) -> String {
        match self {
            TxAction::SessionStart {
                project_path,
                file_count,
            } => {
                format!(
                    "Session started: {} ({} files)",
                    project_path.display(),
                    file_count
                )
            }
            TxAction::SessionEnd {
                total_changes,
                files_modified,
            } => {
                format!(
                    "Session ended: {} changes in {} files",
                    total_changes, files_modified
                )
            }
            TxAction::GoalSet {
                query, intent_type, ..
            } => {
                format!("Goal: {} ({})", query, intent_type)
            }
            TxAction::MutationApplied {
                mutation_type,
                target,
                changes,
                ..
            } => {
                format!("{}: {} ({} changes)", mutation_type, target, changes)
            }
            TxAction::MutationBatch {
                mutations,
                total_changes,
            } => {
                format!(
                    "Batch: {} mutations ({} changes)",
                    mutations.len(),
                    total_changes
                )
            }
            TxAction::FileLoaded { path, .. } => {
                format!("Loaded: {}", path.display())
            }
            TxAction::FileModified { path, changes } => {
                format!("Modified: {} ({} changes)", path.display(), changes)
            }
            TxAction::FileWritten { path } => {
                format!("Written: {}", path.display())
            }
            TxAction::CompileCheck {
                success,
                error_count,
                ..
            } => {
                if *success {
                    "Compile check: OK".to_string()
                } else {
                    format!("Compile check: {} errors", error_count)
                }
            }
            TxAction::Checkpoint { name } => {
                format!("Checkpoint: {}", name)
            }
            TxAction::Undo { target_id } => {
                format!("Undo: entry #{}", target_id)
            }
            TxAction::Redo { target_id } => {
                format!("Redo: entry #{}", target_id)
            }
            TxAction::Custom { name, .. } => {
                format!("Custom: {}", name)
            }
        }
    }

    /// Check if this action is replayable
    pub fn is_replayable(&self) -> bool {
        matches!(
            self,
            TxAction::MutationApplied { .. }
                | TxAction::MutationBatch { .. }
                | TxAction::FileWritten { .. }
        )
    }

    /// Check if this is a mutation action
    pub fn is_mutation(&self) -> bool {
        matches!(
            self,
            TxAction::MutationApplied { .. } | TxAction::MutationBatch { .. }
        )
    }
}

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

    #[test]
    fn test_entry_serialization() {
        let entry = TxEntry::new(
            1,
            100,
            TxAction::MutationApplied {
                mutation_type: "Rename".to_string(),
                target: "old_name -> new_name".to_string(),
                changes: 5,
                mutation_data: None,
                file_path: None,
                pre_state: None,
                post_state: None,
                affected_symbols: vec![],
            },
        );

        let json = serde_json::to_string(&entry).unwrap();
        let deserialized: TxEntry = serde_json::from_str(&json).unwrap();

        assert_eq!(deserialized.id, 1);
        assert_eq!(deserialized.timestamp_ms, 100);
    }

    #[test]
    fn test_action_describe() {
        let action = TxAction::GoalSet {
            query: "rename foo to bar".to_string(),
            intent_type: "RenameIdent".to_string(),
            confidence: 0.95,
        };

        assert!(action.describe().contains("rename foo to bar"));
    }
}