Skip to main content

ryo_storage/storage/
format.rs

1//! Session serialization formats.
2//!
3//! This module provides an abstraction layer for serializing/deserializing
4//! TxLog sessions. Currently supports JSON (pretty and compact), designed
5//! for easy extension to other formats in the future.
6//!
7//! To add a new format:
8//! 1. Implement `SessionFormat` trait
9//! 2. Add to `Format` enum
10//! 3. Update `RyoStorage::with_format()`
11
12use crate::txlog::TxLog;
13use std::fs::File;
14use std::io::BufReader;
15use thiserror::Error;
16
17/// Errors that can occur during session serialization/deserialization.
18#[derive(Debug, Error)]
19pub enum FormatError {
20    /// JSON (de)serialization failure; wraps the underlying
21    /// [`serde_json::Error`].
22    #[error("JSON error: {0}")]
23    Json(#[from] serde_json::Error),
24
25    /// Underlying filesystem I/O failure.
26    #[error("IO error: {0}")]
27    Io(#[from] std::io::Error),
28
29    /// The session payload declared a format identifier that this crate
30    /// does not recognize. Carries the unknown identifier.
31    #[error("Unknown format: {0}")]
32    UnknownFormat(String),
33
34    /// The session payload's format identifier did not match the caller's
35    /// expectation (e.g. expected JSON, found bincode header).
36    #[error("Format mismatch: expected {expected}, got {actual}")]
37    FormatMismatch {
38        /// Format identifier the caller expected to read.
39        expected: String,
40        /// Format identifier actually found in the payload.
41        actual: String,
42    },
43}
44
45/// Result type for format operations.
46pub type FormatResult<T> = Result<T, FormatError>;
47
48/// Supported serialization formats.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum Format {
51    /// JSON format (human-readable, current default)
52    #[default]
53    Json,
54    /// Compact JSON (single line, smaller files)
55    JsonCompact,
56}
57
58impl Format {
59    /// Get the file extension for this format.
60    pub fn extension(&self) -> &'static str {
61        match self {
62            Format::Json => "json",
63            Format::JsonCompact => "json",
64        }
65    }
66
67    /// Get a human-readable name for this format.
68    pub fn name(&self) -> &'static str {
69        match self {
70            Format::Json => "JSON (pretty)",
71            Format::JsonCompact => "JSON (compact)",
72        }
73    }
74
75    /// Check if this is a binary format.
76    pub fn is_binary(&self) -> bool {
77        match self {
78            Format::Json | Format::JsonCompact => false,
79        }
80    }
81
82    /// Parse format from file extension.
83    pub fn from_extension(ext: &str) -> Option<Self> {
84        match ext.to_lowercase().as_str() {
85            "json" => Some(Format::Json),
86            _ => None,
87        }
88    }
89}
90
91/// Trait for session serialization formats.
92///
93/// Implement this trait to add support for new serialization formats.
94pub trait SessionFormat: Send + Sync {
95    /// Get the format identifier.
96    fn format(&self) -> Format;
97
98    /// Serialize a TxLog to bytes.
99    fn serialize(&self, log: &TxLog) -> FormatResult<Vec<u8>>;
100
101    /// Deserialize a TxLog from bytes.
102    fn deserialize(&self, data: &[u8]) -> FormatResult<TxLog>;
103
104    /// Serialize directly to a file (more efficient for large logs).
105    fn serialize_to_file(&self, log: &TxLog, file: File) -> FormatResult<()>;
106
107    /// Deserialize directly from a buffered reader.
108    fn deserialize_from_reader(&self, reader: BufReader<File>) -> FormatResult<TxLog>;
109}
110
111/// JSON serializer (pretty-printed).
112#[derive(Debug, Clone, Copy, Default)]
113pub struct JsonFormat {
114    compact: bool,
115}
116
117impl JsonFormat {
118    /// Create a new pretty-printed JSON serializer.
119    pub fn new() -> Self {
120        Self { compact: false }
121    }
122
123    /// Create a compact JSON serializer.
124    pub fn compact() -> Self {
125        Self { compact: true }
126    }
127}
128
129impl SessionFormat for JsonFormat {
130    fn format(&self) -> Format {
131        if self.compact {
132            Format::JsonCompact
133        } else {
134            Format::Json
135        }
136    }
137
138    fn serialize(&self, log: &TxLog) -> FormatResult<Vec<u8>> {
139        let json = if self.compact {
140            serde_json::to_vec(log)?
141        } else {
142            serde_json::to_vec_pretty(log)?
143        };
144        Ok(json)
145    }
146
147    fn deserialize(&self, data: &[u8]) -> FormatResult<TxLog> {
148        let log = serde_json::from_slice(data)?;
149        Ok(log)
150    }
151
152    fn serialize_to_file(&self, log: &TxLog, file: File) -> FormatResult<()> {
153        if self.compact {
154            serde_json::to_writer(file, log)?;
155        } else {
156            serde_json::to_writer_pretty(file, log)?;
157        }
158        Ok(())
159    }
160
161    fn deserialize_from_reader(&self, reader: BufReader<File>) -> FormatResult<TxLog> {
162        let log = serde_json::from_reader(reader)?;
163        Ok(log)
164    }
165}
166
167/// Get a serializer for the specified format.
168pub fn get_serializer(format: Format) -> Box<dyn SessionFormat> {
169    match format {
170        Format::Json => Box::new(JsonFormat::new()),
171        Format::JsonCompact => Box::new(JsonFormat::compact()),
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::txlog::{TxAction, TxLog};
179
180    fn create_test_log() -> TxLog {
181        let mut log = TxLog::with_project("/test/project");
182        log.log(TxAction::GoalSet {
183            query: "test query".to_string(),
184            intent_type: "test".to_string(),
185            confidence: 0.9,
186        });
187        log.log(TxAction::MutationApplied {
188            mutation_type: "Rename".to_string(),
189            target: "foo -> bar".to_string(),
190            changes: 3,
191            mutation_data: None,
192            file_path: None,
193            pre_state: None,
194            post_state: None,
195            affected_symbols: vec![],
196        });
197        log
198    }
199
200    #[test]
201    fn test_json_roundtrip() {
202        let log = create_test_log();
203        let format = JsonFormat::new();
204
205        let bytes = format.serialize(&log).unwrap();
206        let restored = format.deserialize(&bytes).unwrap();
207
208        assert_eq!(log.entries().len(), restored.entries().len());
209    }
210
211    #[test]
212    fn test_json_compact_roundtrip() {
213        let log = create_test_log();
214        let format = JsonFormat::compact();
215
216        let bytes = format.serialize(&log).unwrap();
217        let restored = format.deserialize(&bytes).unwrap();
218
219        assert_eq!(log.entries().len(), restored.entries().len());
220
221        // Compact should be smaller
222        let pretty_bytes = JsonFormat::new().serialize(&log).unwrap();
223        assert!(bytes.len() < pretty_bytes.len());
224    }
225
226    #[test]
227    fn test_format_extension() {
228        assert_eq!(Format::Json.extension(), "json");
229        assert_eq!(Format::JsonCompact.extension(), "json");
230    }
231
232    #[test]
233    fn test_format_from_extension() {
234        assert_eq!(Format::from_extension("json"), Some(Format::Json));
235        assert_eq!(Format::from_extension("JSON"), Some(Format::Json));
236        assert_eq!(Format::from_extension("unknown"), None);
237    }
238
239    #[test]
240    fn test_format_is_binary() {
241        assert!(!Format::Json.is_binary());
242        assert!(!Format::JsonCompact.is_binary());
243    }
244}