Skip to main content

local_store/
storage.rs

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