Skip to main content

neumann_shell/
wal.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Write-Ahead Log for crash recovery.
3
4use std::{
5    fs::{File, OpenOptions},
6    io::{BufRead, BufReader, Write},
7    path::{Path, PathBuf},
8};
9
10/// Write-Ahead Log for crash recovery.
11///
12/// Logs mutating commands to a file so they can be replayed after loading a snapshot.
13/// The WAL is activated after LOAD and truncated after SAVE.
14pub struct Wal {
15    file: File,
16    path: PathBuf,
17}
18
19impl Wal {
20    /// Opens or creates a WAL file for appending.
21    ///
22    /// # Errors
23    /// Returns an I/O error if the file cannot be opened or created.
24    pub fn open_append(path: &Path) -> std::io::Result<Self> {
25        let file = OpenOptions::new().create(true).append(true).open(path)?;
26        Ok(Self {
27            file,
28            path: path.to_path_buf(),
29        })
30    }
31
32    /// Appends a command to the WAL.
33    ///
34    /// # Errors
35    /// Returns an I/O error if writing or flushing the file fails.
36    pub fn append(&mut self, cmd: &str) -> std::io::Result<()> {
37        writeln!(self.file, "{cmd}")?;
38        self.file.flush()?;
39        self.file.sync_data()
40    }
41
42    /// Truncates the WAL (after a successful save).
43    ///
44    /// # Errors
45    /// Returns an I/O error if the file cannot be truncated.
46    pub fn truncate(&mut self) -> std::io::Result<()> {
47        self.file = File::create(&self.path)?;
48        Ok(())
49    }
50
51    /// Returns the WAL file path.
52    #[must_use]
53    pub fn path(&self) -> &Path {
54        &self.path
55    }
56
57    /// Returns the current WAL file size in bytes.
58    ///
59    /// # Errors
60    /// Returns an I/O error if file metadata cannot be read.
61    pub fn size(&self) -> std::io::Result<u64> {
62        std::fs::metadata(&self.path).map(|m| m.len())
63    }
64
65    /// Reads all commands from the WAL file.
66    ///
67    /// # Errors
68    /// Returns an I/O error if the file cannot be opened or read.
69    pub fn read_commands(path: &Path) -> std::io::Result<Vec<String>> {
70        let file = File::open(path)?;
71        let reader = BufReader::new(file);
72        let mut commands = Vec::new();
73
74        for line in reader.lines() {
75            let cmd = line?;
76            let trimmed = cmd.trim();
77            if !trimmed.is_empty() {
78                commands.push(trimmed.to_string());
79            }
80        }
81
82        Ok(commands)
83    }
84}
85
86/// Recovery mode for WAL replay.
87#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
88pub enum WalRecoveryMode {
89    /// Fail-fast on any error (default, preserves consistency).
90    #[default]
91    Strict,
92    /// Skip corrupted lines and continue replay, report warnings at end.
93    Recover,
94}
95
96/// Result of WAL replay operation.
97#[derive(Debug, Clone)]
98pub struct WalReplayResult {
99    /// Number of commands successfully replayed.
100    pub replayed: usize,
101    /// Errors encountered during replay (only populated in Recover mode).
102    pub errors: Vec<WalReplayError>,
103}
104
105/// Error encountered during WAL replay.
106#[derive(Debug, Clone)]
107pub struct WalReplayError {
108    /// Line number in the WAL file (1-indexed).
109    pub line: usize,
110    /// The command that failed (truncated if >80 chars).
111    pub command: String,
112    /// The error message.
113    pub error: String,
114}
115
116impl WalReplayError {
117    /// Creates a new WAL replay error.
118    #[must_use]
119    pub fn new(line: usize, command: &str, error: String) -> Self {
120        let command = if command.len() > 80 {
121            format!("{}...", &command[..77])
122        } else {
123            command.to_string()
124        };
125        Self {
126            line,
127            command,
128            error,
129        }
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use std::io::Read;
137
138    #[test]
139    fn test_wal_append_and_read() {
140        let dir = std::env::temp_dir();
141        let path = dir.join("test_wal_append.wal");
142
143        // Clean up any existing file
144        let _ = std::fs::remove_file(&path);
145
146        // Write commands
147        {
148            let mut wal = Wal::open_append(&path).unwrap();
149            wal.append("INSERT INTO users VALUES (1, 'Alice')").unwrap();
150            wal.append("INSERT INTO users VALUES (2, 'Bob')").unwrap();
151        }
152
153        // Read commands
154        let commands = Wal::read_commands(&path).unwrap();
155        assert_eq!(commands.len(), 2);
156        assert!(commands[0].contains("Alice"));
157        assert!(commands[1].contains("Bob"));
158
159        // Clean up
160        let _ = std::fs::remove_file(&path);
161    }
162
163    #[test]
164    fn test_wal_truncate() {
165        let dir = std::env::temp_dir();
166        let path = dir.join("test_wal_truncate.wal");
167
168        // Clean up any existing file
169        let _ = std::fs::remove_file(&path);
170
171        // Write and truncate
172        {
173            let mut wal = Wal::open_append(&path).unwrap();
174            wal.append("INSERT INTO users VALUES (1, 'Alice')").unwrap();
175            wal.truncate().unwrap();
176        }
177
178        // Verify empty
179        let mut content = String::new();
180        File::open(&path)
181            .unwrap()
182            .read_to_string(&mut content)
183            .unwrap();
184        assert!(content.is_empty());
185
186        // Clean up
187        let _ = std::fs::remove_file(&path);
188    }
189
190    #[test]
191    fn test_wal_size() {
192        let dir = std::env::temp_dir();
193        let path = dir.join("test_wal_size.wal");
194
195        // Clean up any existing file
196        let _ = std::fs::remove_file(&path);
197
198        let mut wal = Wal::open_append(&path).unwrap();
199        wal.append("test").unwrap();
200        let size = wal.size().unwrap();
201        assert!(size > 0);
202
203        // Clean up
204        let _ = std::fs::remove_file(&path);
205    }
206
207    #[test]
208    fn test_wal_path() {
209        let dir = std::env::temp_dir();
210        let path = dir.join("test_wal_path.wal");
211
212        // Clean up any existing file
213        let _ = std::fs::remove_file(&path);
214
215        let wal = Wal::open_append(&path).unwrap();
216        assert_eq!(wal.path(), path);
217
218        // Clean up
219        let _ = std::fs::remove_file(&path);
220    }
221
222    #[test]
223    fn test_wal_replay_error() {
224        let error = WalReplayError::new(1, "short command", "error msg".to_string());
225        assert_eq!(error.line, 1);
226        assert_eq!(error.command, "short command");
227        assert_eq!(error.error, "error msg");
228    }
229
230    #[test]
231    fn test_wal_replay_error_truncates_long_command() {
232        let long_command = "x".repeat(100);
233        let error = WalReplayError::new(1, &long_command, "error".to_string());
234        assert!(error.command.len() < 100);
235        assert!(error.command.ends_with("..."));
236    }
237
238    #[test]
239    fn test_recovery_mode_default() {
240        let mode = WalRecoveryMode::default();
241        assert_eq!(mode, WalRecoveryMode::Strict);
242    }
243
244    #[test]
245    fn test_wal_replay_result_debug() {
246        let result = WalReplayResult {
247            replayed: 5,
248            errors: vec![],
249        };
250        let debug_str = format!("{result:?}");
251        assert!(debug_str.contains("WalReplayResult"));
252    }
253
254    #[test]
255    fn test_wal_replay_result_clone() {
256        let result = WalReplayResult {
257            replayed: 10,
258            errors: vec![WalReplayError::new(1, "cmd", "err".to_string())],
259        };
260        let cloned = result;
261        assert_eq!(cloned.replayed, 10);
262        assert_eq!(cloned.errors.len(), 1);
263    }
264
265    #[test]
266    fn test_wal_replay_error_debug() {
267        let error = WalReplayError::new(3, "test", "msg".to_string());
268        let debug_str = format!("{error:?}");
269        assert!(debug_str.contains("WalReplayError"));
270    }
271
272    #[test]
273    fn test_wal_replay_error_clone() {
274        let error = WalReplayError::new(7, "cmd", "error".to_string());
275        let cloned = error;
276        assert_eq!(cloned.line, 7);
277        assert_eq!(cloned.command, "cmd");
278    }
279
280    #[test]
281    fn test_wal_recovery_mode_eq() {
282        assert_eq!(WalRecoveryMode::Strict, WalRecoveryMode::Strict);
283        assert_eq!(WalRecoveryMode::Recover, WalRecoveryMode::Recover);
284        assert_ne!(WalRecoveryMode::Strict, WalRecoveryMode::Recover);
285    }
286
287    #[test]
288    fn test_wal_recovery_mode_debug() {
289        let strict = format!("{:?}", WalRecoveryMode::Strict);
290        assert!(strict.contains("Strict"));
291        let recover = format!("{:?}", WalRecoveryMode::Recover);
292        assert!(recover.contains("Recover"));
293    }
294
295    #[test]
296    fn test_wal_recovery_mode_copy() {
297        let mode = WalRecoveryMode::Recover;
298        let copied: WalRecoveryMode = mode;
299        assert_eq!(copied, WalRecoveryMode::Recover);
300    }
301
302    #[test]
303    fn test_read_commands_empty_lines() {
304        let dir = std::env::temp_dir();
305        let path = dir.join("test_wal_empty_lines.wal");
306
307        // Clean up any existing file
308        let _ = std::fs::remove_file(&path);
309
310        // Write commands with empty lines
311        {
312            let mut file = File::create(&path).unwrap();
313            writeln!(file, "cmd1").unwrap();
314            writeln!(file).unwrap(); // empty line
315            writeln!(file, "   ").unwrap(); // whitespace only
316            writeln!(file, "cmd2").unwrap();
317        }
318
319        // Read commands - empty lines should be skipped
320        let commands = Wal::read_commands(&path).unwrap();
321        assert_eq!(commands.len(), 2);
322        assert_eq!(commands[0], "cmd1");
323        assert_eq!(commands[1], "cmd2");
324
325        // Clean up
326        let _ = std::fs::remove_file(&path);
327    }
328
329    #[test]
330    fn test_wal_replay_error_exact_80_chars() {
331        // Test with exactly 80 characters - should NOT truncate
332        let cmd = "x".repeat(80);
333        let error = WalReplayError::new(1, &cmd, "err".to_string());
334        assert_eq!(error.command.len(), 80);
335        assert!(!error.command.ends_with("..."));
336    }
337
338    #[test]
339    fn test_wal_replay_error_81_chars() {
340        // Test with 81 characters - should truncate
341        let cmd = "x".repeat(81);
342        let error = WalReplayError::new(1, &cmd, "err".to_string());
343        assert!(error.command.ends_with("..."));
344        assert_eq!(error.command.len(), 80); // 77 chars + "..."
345    }
346}