ryo-storage 0.1.0

Persistent storage and transaction log for RYO
Documentation
//! Session serialization formats.
//!
//! This module provides an abstraction layer for serializing/deserializing
//! TxLog sessions. Currently supports JSON (pretty and compact), designed
//! for easy extension to other formats in the future.
//!
//! To add a new format:
//! 1. Implement `SessionFormat` trait
//! 2. Add to `Format` enum
//! 3. Update `RyoStorage::with_format()`

use crate::txlog::TxLog;
use std::fs::File;
use std::io::BufReader;
use thiserror::Error;

/// Errors that can occur during session serialization/deserialization.
#[derive(Debug, Error)]
pub enum FormatError {
    /// JSON (de)serialization failure; wraps the underlying
    /// [`serde_json::Error`].
    #[error("JSON error: {0}")]
    Json(#[from] serde_json::Error),

    /// Underlying filesystem I/O failure.
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),

    /// The session payload declared a format identifier that this crate
    /// does not recognize. Carries the unknown identifier.
    #[error("Unknown format: {0}")]
    UnknownFormat(String),

    /// The session payload's format identifier did not match the caller's
    /// expectation (e.g. expected JSON, found bincode header).
    #[error("Format mismatch: expected {expected}, got {actual}")]
    FormatMismatch {
        /// Format identifier the caller expected to read.
        expected: String,
        /// Format identifier actually found in the payload.
        actual: String,
    },
}

/// Result type for format operations.
pub type FormatResult<T> = Result<T, FormatError>;

/// Supported serialization formats.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Format {
    /// JSON format (human-readable, current default)
    #[default]
    Json,
    /// Compact JSON (single line, smaller files)
    JsonCompact,
}

impl Format {
    /// Get the file extension for this format.
    pub fn extension(&self) -> &'static str {
        match self {
            Format::Json => "json",
            Format::JsonCompact => "json",
        }
    }

    /// Get a human-readable name for this format.
    pub fn name(&self) -> &'static str {
        match self {
            Format::Json => "JSON (pretty)",
            Format::JsonCompact => "JSON (compact)",
        }
    }

    /// Check if this is a binary format.
    pub fn is_binary(&self) -> bool {
        match self {
            Format::Json | Format::JsonCompact => false,
        }
    }

    /// Parse format from file extension.
    pub fn from_extension(ext: &str) -> Option<Self> {
        match ext.to_lowercase().as_str() {
            "json" => Some(Format::Json),
            _ => None,
        }
    }
}

/// Trait for session serialization formats.
///
/// Implement this trait to add support for new serialization formats.
pub trait SessionFormat: Send + Sync {
    /// Get the format identifier.
    fn format(&self) -> Format;

    /// Serialize a TxLog to bytes.
    fn serialize(&self, log: &TxLog) -> FormatResult<Vec<u8>>;

    /// Deserialize a TxLog from bytes.
    fn deserialize(&self, data: &[u8]) -> FormatResult<TxLog>;

    /// Serialize directly to a file (more efficient for large logs).
    fn serialize_to_file(&self, log: &TxLog, file: File) -> FormatResult<()>;

    /// Deserialize directly from a buffered reader.
    fn deserialize_from_reader(&self, reader: BufReader<File>) -> FormatResult<TxLog>;
}

/// JSON serializer (pretty-printed).
#[derive(Debug, Clone, Copy, Default)]
pub struct JsonFormat {
    compact: bool,
}

impl JsonFormat {
    /// Create a new pretty-printed JSON serializer.
    pub fn new() -> Self {
        Self { compact: false }
    }

    /// Create a compact JSON serializer.
    pub fn compact() -> Self {
        Self { compact: true }
    }
}

impl SessionFormat for JsonFormat {
    fn format(&self) -> Format {
        if self.compact {
            Format::JsonCompact
        } else {
            Format::Json
        }
    }

    fn serialize(&self, log: &TxLog) -> FormatResult<Vec<u8>> {
        let json = if self.compact {
            serde_json::to_vec(log)?
        } else {
            serde_json::to_vec_pretty(log)?
        };
        Ok(json)
    }

    fn deserialize(&self, data: &[u8]) -> FormatResult<TxLog> {
        let log = serde_json::from_slice(data)?;
        Ok(log)
    }

    fn serialize_to_file(&self, log: &TxLog, file: File) -> FormatResult<()> {
        if self.compact {
            serde_json::to_writer(file, log)?;
        } else {
            serde_json::to_writer_pretty(file, log)?;
        }
        Ok(())
    }

    fn deserialize_from_reader(&self, reader: BufReader<File>) -> FormatResult<TxLog> {
        let log = serde_json::from_reader(reader)?;
        Ok(log)
    }
}

/// Get a serializer for the specified format.
pub fn get_serializer(format: Format) -> Box<dyn SessionFormat> {
    match format {
        Format::Json => Box::new(JsonFormat::new()),
        Format::JsonCompact => Box::new(JsonFormat::compact()),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::txlog::{TxAction, TxLog};

    fn create_test_log() -> TxLog {
        let mut log = TxLog::with_project("/test/project");
        log.log(TxAction::GoalSet {
            query: "test query".to_string(),
            intent_type: "test".to_string(),
            confidence: 0.9,
        });
        log.log(TxAction::MutationApplied {
            mutation_type: "Rename".to_string(),
            target: "foo -> bar".to_string(),
            changes: 3,
            mutation_data: None,
            file_path: None,
            pre_state: None,
            post_state: None,
            affected_symbols: vec![],
        });
        log
    }

    #[test]
    fn test_json_roundtrip() {
        let log = create_test_log();
        let format = JsonFormat::new();

        let bytes = format.serialize(&log).unwrap();
        let restored = format.deserialize(&bytes).unwrap();

        assert_eq!(log.entries().len(), restored.entries().len());
    }

    #[test]
    fn test_json_compact_roundtrip() {
        let log = create_test_log();
        let format = JsonFormat::compact();

        let bytes = format.serialize(&log).unwrap();
        let restored = format.deserialize(&bytes).unwrap();

        assert_eq!(log.entries().len(), restored.entries().len());

        // Compact should be smaller
        let pretty_bytes = JsonFormat::new().serialize(&log).unwrap();
        assert!(bytes.len() < pretty_bytes.len());
    }

    #[test]
    fn test_format_extension() {
        assert_eq!(Format::Json.extension(), "json");
        assert_eq!(Format::JsonCompact.extension(), "json");
    }

    #[test]
    fn test_format_from_extension() {
        assert_eq!(Format::from_extension("json"), Some(Format::Json));
        assert_eq!(Format::from_extension("JSON"), Some(Format::Json));
        assert_eq!(Format::from_extension("unknown"), None);
    }

    #[test]
    fn test_format_is_binary() {
        assert!(!Format::Json.is_binary());
        assert!(!Format::JsonCompact.is_binary());
    }
}