Skip to main content

local_store/
storage.rs

1//! Raw file storage with ACID guarantees.
2//!
3//! Provides atomic file operations, format dispatch, and file locking.
4//! This module is intentionally free of any migration or versioning logic.
5
6use crate::atomic_io;
7use crate::errors::{IoOperationKind, StoreError};
8use crate::format_convert::json_to_toml;
9use serde_json::Value as JsonValue;
10use std::fs::{self, File};
11use std::io::Write as IoWrite;
12use std::path::{Path, PathBuf};
13
14/// File format strategy for storage operations.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum FormatStrategy {
17    /// TOML format (recommended for human-editable configs)
18    Toml,
19    /// JSON format
20    Json,
21}
22
23/// Configuration for atomic write operations.
24#[derive(Debug, Clone)]
25pub struct AtomicWriteConfig {
26    /// Number of times to retry rename operation (default: 3)
27    pub retry_count: usize,
28    /// Whether to clean up old temporary files (best effort)
29    pub cleanup_tmp_files: bool,
30}
31
32impl Default for AtomicWriteConfig {
33    fn default() -> Self {
34        Self {
35            retry_count: 3,
36            cleanup_tmp_files: true,
37        }
38    }
39}
40
41/// Behavior when loading a file that does not exist.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum LoadBehavior {
44    /// Proceed normally with empty content if file is missing.
45    CreateIfMissing,
46    /// Serialize `default_value` and write to disk if file is missing.
47    SaveIfMissing,
48    /// Return an error if file is missing.
49    ErrorIfMissing,
50}
51
52/// Strategy for file storage operations.
53#[derive(Debug, Clone)]
54pub struct FileStorageStrategy {
55    /// File format to use.
56    pub format: FormatStrategy,
57    /// Atomic write configuration.
58    pub atomic_write: AtomicWriteConfig,
59    /// Behavior when file does not exist.
60    pub load_behavior: LoadBehavior,
61    /// Default value used when `SaveIfMissing` is set (as JSON Value).
62    pub default_value: Option<JsonValue>,
63}
64
65impl Default for FileStorageStrategy {
66    fn default() -> Self {
67        Self {
68            format: FormatStrategy::Toml,
69            atomic_write: AtomicWriteConfig::default(),
70            load_behavior: LoadBehavior::CreateIfMissing,
71            default_value: None,
72        }
73    }
74}
75
76impl FileStorageStrategy {
77    /// Create a new strategy with default values.
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Set the file format.
83    pub fn with_format(mut self, format: FormatStrategy) -> Self {
84        self.format = format;
85        self
86    }
87
88    /// Set the retry count for atomic writes.
89    pub fn with_retry_count(mut self, count: usize) -> Self {
90        self.atomic_write.retry_count = count;
91        self
92    }
93
94    /// Set whether to clean up temporary files.
95    pub fn with_cleanup(mut self, cleanup: bool) -> Self {
96        self.atomic_write.cleanup_tmp_files = cleanup;
97        self
98    }
99
100    /// Set the load behavior.
101    pub fn with_load_behavior(mut self, behavior: LoadBehavior) -> Self {
102        self.load_behavior = behavior;
103        self
104    }
105
106    /// Set the default value used when `SaveIfMissing` is set.
107    pub fn with_default_value(mut self, value: JsonValue) -> Self {
108        self.default_value = Some(value);
109        self
110    }
111}
112
113/// Raw file storage with ACID guarantees.
114///
115/// Holds only `path` and `strategy`; no migration or versioning state.
116/// Higher-level wrappers (e.g. `VersionedFileStorage`) own the migration logic
117/// and delegate raw IO to this struct.
118pub struct FileStorage {
119    path: PathBuf,
120    strategy: FileStorageStrategy,
121}
122
123impl FileStorage {
124    /// Create a new `FileStorage` and handle missing-file behavior.
125    ///
126    /// - `CreateIfMissing`: succeeds without writing when file is absent.
127    /// - `SaveIfMissing`: serializes `strategy.default_value` (or `{}`) and writes it.
128    /// - `ErrorIfMissing`: returns `StoreError` when file is absent.
129    pub fn new(path: PathBuf, strategy: FileStorageStrategy) -> Result<Self, StoreError> {
130        let file_was_missing = !path.exists();
131
132        if file_was_missing {
133            match strategy.load_behavior {
134                LoadBehavior::ErrorIfMissing => {
135                    return Err(StoreError::IoError {
136                        operation: IoOperationKind::Read,
137                        path: path.display().to_string(),
138                        context: None,
139                        error: "File not found".to_string(),
140                    });
141                }
142                LoadBehavior::CreateIfMissing => {
143                    // Nothing to do; caller reads via read_string() on demand.
144                }
145                LoadBehavior::SaveIfMissing => {
146                    // Serialize default_value (or "{}") and persist immediately.
147                    let storage = Self { path, strategy };
148                    let content = storage.default_value_as_string()?;
149                    storage.write_string(&content)?;
150                    return Ok(storage);
151                }
152            }
153        }
154
155        Ok(Self { path, strategy })
156    }
157
158    /// Read raw file contents as a string.
159    ///
160    /// Returns the content exactly as stored on disk.
161    pub fn read_string(&self) -> Result<String, StoreError> {
162        fs::read_to_string(&self.path).map_err(|e| StoreError::IoError {
163            operation: IoOperationKind::Read,
164            path: self.path.display().to_string(),
165            context: None,
166            error: e.to_string(),
167        })
168    }
169
170    /// Write `content` to the file atomically.
171    ///
172    /// Creates parent directories as needed, writes to a temp file, syncs,
173    /// then renames atomically (with retry according to `strategy.atomic_write`).
174    pub fn write_string(&self, content: &str) -> Result<(), StoreError> {
175        // Ensure parent directory exists.
176        if let Some(parent) = self.path.parent() {
177            if !parent.as_os_str().is_empty() && !parent.exists() {
178                fs::create_dir_all(parent).map_err(|e| StoreError::IoError {
179                    operation: IoOperationKind::CreateDir,
180                    path: parent.display().to_string(),
181                    context: Some("parent directory".to_string()),
182                    error: e.to_string(),
183                })?;
184            }
185        }
186
187        let tmp_path = atomic_io::get_temp_path(&self.path)?;
188
189        let mut tmp_file = File::create(&tmp_path).map_err(|e| StoreError::IoError {
190            operation: IoOperationKind::Create,
191            path: tmp_path.display().to_string(),
192            context: Some("temporary file".to_string()),
193            error: e.to_string(),
194        })?;
195
196        tmp_file
197            .write_all(content.as_bytes())
198            .map_err(|e| StoreError::IoError {
199                operation: IoOperationKind::Write,
200                path: tmp_path.display().to_string(),
201                context: Some("temporary file".to_string()),
202                error: e.to_string(),
203            })?;
204
205        tmp_file.sync_all().map_err(|e| StoreError::IoError {
206            operation: IoOperationKind::Sync,
207            path: tmp_path.display().to_string(),
208            context: Some("temporary file".to_string()),
209            error: e.to_string(),
210        })?;
211
212        drop(tmp_file);
213
214        atomic_io::atomic_rename(
215            &tmp_path,
216            &self.path,
217            self.strategy.atomic_write.retry_count,
218        )?;
219
220        if self.strategy.atomic_write.cleanup_tmp_files {
221            let _ = atomic_io::cleanup_temp_files(&self.path);
222        }
223
224        Ok(())
225    }
226
227    /// Returns a reference to the storage file path.
228    pub fn path(&self) -> &Path {
229        &self.path
230    }
231
232    /// Returns a reference to the storage strategy.
233    pub fn strategy(&self) -> &FileStorageStrategy {
234        &self.strategy
235    }
236
237    // -------------------------------------------------------------------------
238    // Private helpers
239    // -------------------------------------------------------------------------
240
241    /// Serialize `strategy.default_value` (or `"{}"`) into the on-disk format.
242    fn default_value_as_string(&self) -> Result<String, StoreError> {
243        let json_value = self
244            .strategy
245            .default_value
246            .clone()
247            .unwrap_or(JsonValue::Object(Default::default()));
248
249        match self.strategy.format {
250            FormatStrategy::Json => {
251                serde_json::to_string_pretty(&json_value).map_err(|e| StoreError::IoError {
252                    operation: IoOperationKind::Write,
253                    path: self.path.display().to_string(),
254                    context: Some("serialize default value".to_string()),
255                    error: e.to_string(),
256                })
257            }
258            FormatStrategy::Toml => {
259                let toml_value = json_to_toml(&json_value)?;
260                toml::to_string_pretty(&toml_value).map_err(|e| StoreError::IoError {
261                    operation: IoOperationKind::Write,
262                    path: self.path.display().to_string(),
263                    context: Some("serialize default value as toml".to_string()),
264                    error: e.to_string(),
265                })
266            }
267        }
268    }
269}
270
271// ---------------------------------------------------------------------------
272// Tests
273// ---------------------------------------------------------------------------
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use std::fs;
279    use tempfile::TempDir;
280
281    // -----------------------------------------------------------------------
282    // R-S1-1: new() + SaveIfMissing writes default_value
283    // -----------------------------------------------------------------------
284
285    #[test]
286    fn test_new_creates_file_with_save_if_missing() {
287        let dir = TempDir::new().unwrap();
288        let path = dir.path().join("config.toml");
289
290        let strategy = FileStorageStrategy::new()
291            .with_load_behavior(LoadBehavior::SaveIfMissing)
292            .with_default_value(serde_json::json!({"key": "value"}));
293
294        assert!(!path.exists());
295        let storage = FileStorage::new(path.clone(), strategy).unwrap();
296        assert!(path.exists(), "file must be created for SaveIfMissing");
297        assert_eq!(storage.path(), path.as_path());
298    }
299
300    #[test]
301    fn test_new_no_file_create_if_missing() {
302        let dir = TempDir::new().unwrap();
303        let path = dir.path().join("missing.toml");
304
305        let strategy = FileStorageStrategy::new().with_load_behavior(LoadBehavior::CreateIfMissing);
306
307        let storage = FileStorage::new(path.clone(), strategy).unwrap();
308        // File is NOT written for CreateIfMissing.
309        assert!(!path.exists());
310        assert_eq!(storage.path(), path.as_path());
311    }
312
313    #[test]
314    fn test_new_error_if_missing() {
315        let dir = TempDir::new().unwrap();
316        let path = dir.path().join("absent.toml");
317
318        let strategy = FileStorageStrategy::new().with_load_behavior(LoadBehavior::ErrorIfMissing);
319
320        let result = FileStorage::new(path, strategy);
321        assert!(result.is_err());
322        assert!(matches!(
323            result,
324            Err(StoreError::IoError {
325                operation: IoOperationKind::Read,
326                ..
327            })
328        ));
329    }
330
331    // -----------------------------------------------------------------------
332    // read_string / write_string (core API)
333    // -----------------------------------------------------------------------
334
335    #[test]
336    fn test_read_string_returns_file_content() {
337        let dir = TempDir::new().unwrap();
338        let path = dir.path().join("data.json");
339        fs::write(&path, r#"{"hello":"world"}"#).unwrap();
340
341        let strategy = FileStorageStrategy::new()
342            .with_format(FormatStrategy::Json)
343            .with_load_behavior(LoadBehavior::ErrorIfMissing);
344
345        let storage = FileStorage::new(path, strategy).unwrap();
346        let content = storage.read_string().unwrap();
347        assert_eq!(content, r#"{"hello":"world"}"#);
348    }
349
350    #[test]
351    fn test_write_string_creates_and_reads_back() {
352        let dir = TempDir::new().unwrap();
353        let path = dir.path().join("out.json");
354
355        let strategy = FileStorageStrategy::new()
356            .with_format(FormatStrategy::Json)
357            .with_load_behavior(LoadBehavior::CreateIfMissing);
358
359        let storage = FileStorage::new(path.clone(), strategy).unwrap();
360        storage.write_string(r#"{"x":1}"#).unwrap();
361
362        let back = storage.read_string().unwrap();
363        assert_eq!(back, r#"{"x":1}"#);
364    }
365
366    #[test]
367    fn test_write_string_creates_parent_dirs() {
368        let dir = TempDir::new().unwrap();
369        let path = dir.path().join("a").join("b").join("c.toml");
370
371        let strategy = FileStorageStrategy::new().with_load_behavior(LoadBehavior::CreateIfMissing);
372
373        let storage = FileStorage::new(path.clone(), strategy).unwrap();
374        storage.write_string("").unwrap();
375        assert!(path.exists());
376    }
377
378    // -----------------------------------------------------------------------
379    // R-S1-2: atomic_rename retry_count behaviour
380    // -----------------------------------------------------------------------
381
382    #[test]
383    fn test_atomic_write_no_tmp_left() {
384        let dir = TempDir::new().unwrap();
385        let path = dir.path().join("atomic.toml");
386        let strategy = FileStorageStrategy::default();
387
388        let storage = FileStorage::new(path.clone(), strategy).unwrap();
389        storage.write_string("hello = true\n").unwrap();
390
391        let tmp_files: Vec<_> = fs::read_dir(dir.path())
392            .unwrap()
393            .filter_map(|e| e.ok())
394            .filter(|e| {
395                e.file_name()
396                    .to_string_lossy()
397                    .starts_with(".atomic.toml.tmp")
398            })
399            .collect();
400        assert_eq!(tmp_files.len(), 0, "no temp files should remain");
401    }
402
403    // -----------------------------------------------------------------------
404    // R-S1-3: cleanup_temp_files removes stale .tmp files
405    // -----------------------------------------------------------------------
406
407    #[test]
408    fn test_cleanup_removes_stale_tmp_files() {
409        let dir = TempDir::new().unwrap();
410        let path = dir.path().join("cfg.toml");
411
412        let fake1 = dir.path().join(".cfg.toml.tmp.11111");
413        let fake2 = dir.path().join(".cfg.toml.tmp.22222");
414        fs::write(&fake1, "stale1").unwrap();
415        fs::write(&fake2, "stale2").unwrap();
416
417        let strategy = FileStorageStrategy::default();
418        let storage = FileStorage::new(path.clone(), strategy).unwrap();
419        storage.write_string("cfg = true\n").unwrap();
420
421        assert!(!fake1.exists(), "stale tmp 1 should be removed");
422        assert!(!fake2.exists(), "stale tmp 2 should be removed");
423    }
424
425    #[test]
426    fn test_no_cleanup_keeps_stale_tmp_files() {
427        let dir = TempDir::new().unwrap();
428        let path = dir.path().join("no_clean.toml");
429        let fake = dir.path().join(".no_clean.toml.tmp.99999");
430        fs::write(&fake, "stale").unwrap();
431
432        let strategy = FileStorageStrategy::new().with_cleanup(false);
433        let storage = FileStorage::new(path.clone(), strategy).unwrap();
434        storage.write_string("x = 1\n").unwrap();
435
436        assert!(fake.exists(), "stale tmp must remain when cleanup=false");
437    }
438
439    // -----------------------------------------------------------------------
440    // R-S1-4: FormatStrategy::Toml / Json dispatch via default_value_as_string
441    // -----------------------------------------------------------------------
442
443    #[test]
444    fn test_save_if_missing_json_format() {
445        let dir = TempDir::new().unwrap();
446        let path = dir.path().join("data.json");
447
448        let strategy = FileStorageStrategy::new()
449            .with_format(FormatStrategy::Json)
450            .with_load_behavior(LoadBehavior::SaveIfMissing)
451            .with_default_value(serde_json::json!({"items": []}));
452
453        FileStorage::new(path.clone(), strategy).unwrap();
454        let content = fs::read_to_string(&path).unwrap();
455        // Must parse as valid JSON.
456        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
457        assert!(parsed.get("items").is_some());
458    }
459
460    #[test]
461    fn test_save_if_missing_toml_format() {
462        let dir = TempDir::new().unwrap();
463        let path = dir.path().join("data.toml");
464
465        let strategy = FileStorageStrategy::new()
466            .with_format(FormatStrategy::Toml)
467            .with_load_behavior(LoadBehavior::SaveIfMissing)
468            .with_default_value(serde_json::json!({"name": "alice"}));
469
470        FileStorage::new(path.clone(), strategy).unwrap();
471        let content = fs::read_to_string(&path).unwrap();
472        // Must parse as valid TOML.
473        let parsed: toml::Value = toml::from_str(&content).unwrap();
474        assert!(parsed.get("name").is_some());
475    }
476
477    // -----------------------------------------------------------------------
478    // Strategy builder
479    // -----------------------------------------------------------------------
480
481    #[test]
482    fn test_strategy_builder() {
483        let s = FileStorageStrategy::new()
484            .with_format(FormatStrategy::Json)
485            .with_retry_count(5)
486            .with_cleanup(false)
487            .with_load_behavior(LoadBehavior::ErrorIfMissing);
488
489        assert_eq!(s.format, FormatStrategy::Json);
490        assert_eq!(s.atomic_write.retry_count, 5);
491        assert!(!s.atomic_write.cleanup_tmp_files);
492        assert_eq!(s.load_behavior, LoadBehavior::ErrorIfMissing);
493    }
494
495    #[test]
496    fn test_atomic_write_config_default() {
497        let cfg = AtomicWriteConfig::default();
498        assert_eq!(cfg.retry_count, 3);
499        assert!(cfg.cleanup_tmp_files);
500    }
501
502    #[test]
503    fn test_format_strategy_equality() {
504        assert_eq!(FormatStrategy::Toml, FormatStrategy::Toml);
505        assert_ne!(FormatStrategy::Toml, FormatStrategy::Json);
506    }
507
508    #[test]
509    fn test_load_behavior_equality() {
510        assert_eq!(LoadBehavior::CreateIfMissing, LoadBehavior::CreateIfMissing);
511        assert_ne!(LoadBehavior::CreateIfMissing, LoadBehavior::ErrorIfMissing);
512    }
513}