Skip to main content

ftui_runtime/
state_persistence.rs

1//! Widget state persistence for save/restore across sessions.
2//!
3//! This module provides the [`StateRegistry`] and [`StorageBackend`] infrastructure
4//! for persisting widget state. It works with the [`Stateful`] trait from `ftui-widgets`.
5//!
6//! # Architecture
7//!
8//! ```text
9//! ┌──────────────────────────────────────────────────────────────┐
10//! │                      StateRegistry                            │
11//! │   - In-memory cache of widget states                          │
12//! │   - Delegates to StorageBackend for persistence               │
13//! │   - Provides load/save/clear operations                       │
14//! └──────────────────────────────────────────────────────────────┘
15//!                              │
16//!                              ▼
17//! ┌──────────────────────────────────────────────────────────────┐
18//! │                     StorageBackend                            │
19//! │   - MemoryStorage: in-memory (testing, ephemeral)             │
20//! │   - FileStorage: JSON file (requires state-persistence)       │
21//! └──────────────────────────────────────────────────────────────┘
22//! ```
23//!
24//! # Design Invariants
25//!
26//! 1. **Graceful degradation**: Storage failures never panic; operations return `Result`.
27//! 2. **Atomic writes**: File storage uses write-rename pattern to prevent corruption.
28//! 3. **Partial load tolerance**: Missing or corrupt entries use `Default::default()`.
29//! 4. **Type safety**: Registry is type-erased internally but type-safe at boundaries.
30//!
31//! # Failure Modes
32//!
33//! | Failure | Cause | Behavior |
34//! |---------|-------|----------|
35//! | `StorageError::Io` | File I/O failure | Returns error, cache unaffected |
36//! | `StorageError::Serialization` | JSON encode/decode | Entry skipped, logged |
37//! | `StorageError::Corruption` | Invalid file format | Load returns partial data |
38//! | Missing entry | First run, key changed | `Default::default()` used |
39//!
40//! # Feature Gates
41//!
42//! - `state-persistence`: Enables `FileStorage` with JSON serialization.
43//!   Without this feature, only `MemoryStorage` is available.
44//!
45//! [`Stateful`]: ftui_widgets::stateful::Stateful
46
47use std::collections::HashMap;
48use std::fmt;
49use std::sync::{Arc, RwLock};
50
51// ─────────────────────────────────────────────────────────────────────────────
52// Error Types
53// ─────────────────────────────────────────────────────────────────────────────
54
55/// Errors that can occur during state storage operations.
56#[derive(Debug)]
57pub enum StorageError {
58    /// I/O error during file operations.
59    Io(std::io::Error),
60    /// Serialization or deserialization error.
61    #[cfg(feature = "state-persistence")]
62    Serialization(String),
63    /// Storage file is corrupted or invalid format.
64    Corruption(String),
65    /// Backend is not available (e.g., file storage without feature).
66    Unavailable(String),
67}
68
69impl fmt::Display for StorageError {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        match self {
72            StorageError::Io(e) => write!(f, "I/O error: {e}"),
73            #[cfg(feature = "state-persistence")]
74            StorageError::Serialization(msg) => write!(f, "serialization error: {msg}"),
75            StorageError::Corruption(msg) => write!(f, "storage corruption: {msg}"),
76            StorageError::Unavailable(msg) => write!(f, "storage unavailable: {msg}"),
77        }
78    }
79}
80
81impl std::error::Error for StorageError {
82    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
83        match self {
84            StorageError::Io(e) => Some(e),
85            #[cfg(feature = "state-persistence")]
86            StorageError::Serialization(_) => None,
87            StorageError::Corruption(_) => None,
88            StorageError::Unavailable(_) => None,
89        }
90    }
91}
92
93impl From<std::io::Error> for StorageError {
94    fn from(e: std::io::Error) -> Self {
95        StorageError::Io(e)
96    }
97}
98
99/// Result type for storage operations.
100pub type StorageResult<T> = Result<T, StorageError>;
101
102// ─────────────────────────────────────────────────────────────────────────────
103// Storage Backend Trait
104// ─────────────────────────────────────────────────────────────────────────────
105
106/// A serialized state entry with version metadata.
107///
108/// This is the storage format used by backends. The actual state data
109/// is serialized to bytes by the caller.
110#[derive(Clone, Debug)]
111pub struct StoredEntry {
112    /// The canonical state key (widget_type::instance_id).
113    pub key: String,
114    /// Schema version from `Stateful::state_version()`.
115    pub version: u32,
116    /// Serialized state data (JSON bytes with `state-persistence` feature).
117    pub data: Vec<u8>,
118}
119
120/// Trait for pluggable state storage backends.
121///
122/// Implementations must be thread-safe (`Send + Sync`) to support
123/// concurrent access from the registry.
124///
125/// # Implementation Notes
126///
127/// - `load_all` should be resilient to partial corruption.
128/// - `save_all` should be atomic (write-then-rename pattern for files).
129/// - `clear` should remove all stored state for the application.
130pub trait StorageBackend: Send + Sync {
131    /// Human-readable name for logging.
132    fn name(&self) -> &str;
133
134    /// Load all stored state entries.
135    ///
136    /// Returns an empty map if no state exists (first run).
137    /// Skips corrupted entries rather than failing entirely.
138    fn load_all(&self) -> StorageResult<HashMap<String, StoredEntry>>;
139
140    /// Save all state entries atomically.
141    ///
142    /// This should replace all existing state (not merge).
143    fn save_all(&self, entries: &HashMap<String, StoredEntry>) -> StorageResult<()>;
144
145    /// Clear all stored state.
146    fn clear(&self) -> StorageResult<()>;
147
148    /// Check if the backend is available and functional.
149    fn is_available(&self) -> bool {
150        true
151    }
152}
153
154// ─────────────────────────────────────────────────────────────────────────────
155// Memory Storage (always available)
156// ─────────────────────────────────────────────────────────────────────────────
157
158/// In-memory storage backend for testing and ephemeral state.
159///
160/// State is lost when the process exits. Useful for:
161/// - Unit testing widget persistence logic
162/// - Applications that don't need cross-session persistence
163/// - Development/debugging without file I/O
164#[derive(Default)]
165pub struct MemoryStorage {
166    data: RwLock<HashMap<String, StoredEntry>>,
167}
168
169impl MemoryStorage {
170    /// Create a new empty memory storage.
171    #[must_use]
172    pub fn new() -> Self {
173        Self::default()
174    }
175
176    /// Create memory storage pre-populated with entries.
177    #[must_use]
178    pub fn with_entries(entries: HashMap<String, StoredEntry>) -> Self {
179        Self {
180            data: RwLock::new(entries),
181        }
182    }
183}
184
185impl StorageBackend for MemoryStorage {
186    fn name(&self) -> &str {
187        "MemoryStorage"
188    }
189
190    fn load_all(&self) -> StorageResult<HashMap<String, StoredEntry>> {
191        let guard = self
192            .data
193            .read()
194            .map_err(|_| StorageError::Corruption("lock poisoned".into()))?;
195        Ok(guard.clone())
196    }
197
198    fn save_all(&self, entries: &HashMap<String, StoredEntry>) -> StorageResult<()> {
199        let mut guard = self
200            .data
201            .write()
202            .map_err(|_| StorageError::Corruption("lock poisoned".into()))?;
203        *guard = entries.clone();
204        Ok(())
205    }
206
207    fn clear(&self) -> StorageResult<()> {
208        let mut guard = self
209            .data
210            .write()
211            .map_err(|_| StorageError::Corruption("lock poisoned".into()))?;
212        guard.clear();
213        Ok(())
214    }
215}
216
217impl fmt::Debug for MemoryStorage {
218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219        let count = self.data.read().map(|g| g.len()).unwrap_or(0);
220        f.debug_struct("MemoryStorage")
221            .field("entries", &count)
222            .finish()
223    }
224}
225
226// ─────────────────────────────────────────────────────────────────────────────
227// File Storage (requires state-persistence feature)
228// ─────────────────────────────────────────────────────────────────────────────
229
230#[cfg(feature = "state-persistence")]
231mod file_storage {
232    use super::*;
233    use serde::{Deserialize, Serialize};
234    use std::fs::{self, File};
235    use std::io::{BufReader, BufWriter, Write};
236    use std::path::{Path, PathBuf};
237
238    /// File format for stored state (JSON).
239    #[derive(Serialize, Deserialize)]
240    struct StateFile {
241        /// Format version for future migrations.
242        format_version: u32,
243        /// Map of canonical key -> entry.
244        entries: HashMap<String, FileEntry>,
245    }
246
247    /// Serialized entry in the state file.
248    #[derive(Serialize, Deserialize)]
249    struct FileEntry {
250        version: u32,
251        /// Base64-encoded data for binary safety.
252        data_base64: String,
253    }
254
255    impl StateFile {
256        const FORMAT_VERSION: u32 = 1;
257
258        fn new() -> Self {
259            Self {
260                format_version: Self::FORMAT_VERSION,
261                entries: HashMap::new(),
262            }
263        }
264    }
265
266    /// File-based storage backend using JSON.
267    ///
268    /// State is persisted to a JSON file with atomic write-rename pattern.
269    /// Suitable for applications that need cross-session persistence.
270    ///
271    /// # File Format
272    ///
273    /// ```json
274    /// {
275    ///   "format_version": 1,
276    ///   "entries": {
277    ///     "ScrollView::main": {
278    ///       "version": 1,
279    ///       "data_base64": "eyJzY3JvbGxfb2Zmc2V0IjogNDJ9"
280    ///     }
281    ///   }
282    /// }
283    /// ```
284    ///
285    /// # Atomic Writes
286    ///
287    /// Writes use a temporary file + rename pattern to prevent corruption:
288    /// 1. Write to `{path}.tmp`
289    /// 2. Flush and sync
290    /// 3. Rename `{path}.tmp` -> `{path}`
291    pub struct FileStorage {
292        path: PathBuf,
293    }
294
295    impl FileStorage {
296        /// Create a file storage at the given path.
297        ///
298        /// The file does not need to exist; it will be created on first save.
299        #[must_use]
300        pub fn new(path: impl AsRef<Path>) -> Self {
301            Self {
302                path: path.as_ref().to_path_buf(),
303            }
304        }
305
306        /// Create storage at the default location for the application.
307        ///
308        /// Uses `$XDG_STATE_HOME/ftui/{app_name}/state.json` on Linux,
309        /// or platform-appropriate equivalents.
310        #[must_use]
311        pub fn default_for_app(app_name: &str) -> Self {
312            let base = dirs_or_fallback();
313            let path = base.join("ftui").join(app_name).join("state.json");
314            Self { path }
315        }
316
317        fn temp_path(&self) -> PathBuf {
318            let mut tmp = self.path.clone();
319            tmp.set_extension("json.tmp");
320            tmp
321        }
322    }
323
324    /// Get state directory, falling back to current dir if unavailable.
325    fn dirs_or_fallback() -> PathBuf {
326        // Try XDG_STATE_HOME first
327        if let Ok(state_home) = std::env::var("XDG_STATE_HOME") {
328            return PathBuf::from(state_home);
329        }
330        // Fall back to ~/.local/state
331        if let Ok(home) = std::env::var("HOME") {
332            return PathBuf::from(home).join(".local").join("state");
333        }
334        // Last resort: current directory
335        PathBuf::from(".")
336    }
337
338    impl StorageBackend for FileStorage {
339        fn name(&self) -> &str {
340            "FileStorage"
341        }
342
343        fn load_all(&self) -> StorageResult<HashMap<String, StoredEntry>> {
344            if !self.path.exists() {
345                // First run - no state yet
346                return Ok(HashMap::new());
347            }
348
349            let file = File::open(&self.path)?;
350            let reader = BufReader::new(file);
351
352            let state_file: StateFile = serde_json::from_reader(reader).map_err(|e| {
353                StorageError::Serialization(format!("failed to parse state file: {e}"))
354            })?;
355
356            // Validate format version
357            if state_file.format_version != StateFile::FORMAT_VERSION {
358                tracing::warn!(
359                    stored = state_file.format_version,
360                    expected = StateFile::FORMAT_VERSION,
361                    "state file format version mismatch, ignoring stored state"
362                );
363                return Ok(HashMap::new());
364            }
365
366            // Convert file entries to StoredEntry
367            let mut result = HashMap::new();
368            for (key, entry) in state_file.entries {
369                use base64::Engine;
370                let data = match base64::engine::general_purpose::STANDARD
371                    .decode(&entry.data_base64)
372                {
373                    Ok(d) => d,
374                    Err(e) => {
375                        tracing::warn!(key = %key, error = %e, "failed to decode state entry, skipping");
376                        continue;
377                    }
378                };
379                result.insert(
380                    key.clone(),
381                    StoredEntry {
382                        key,
383                        version: entry.version,
384                        data,
385                    },
386                );
387            }
388
389            Ok(result)
390        }
391
392        fn save_all(&self, entries: &HashMap<String, StoredEntry>) -> StorageResult<()> {
393            use base64::Engine;
394
395            // Ensure parent directory exists
396            if let Some(parent) = self.path.parent() {
397                fs::create_dir_all(parent)?;
398            }
399
400            // Build file content
401            let mut state_file = StateFile::new();
402            for (key, entry) in entries {
403                state_file.entries.insert(
404                    key.clone(),
405                    FileEntry {
406                        version: entry.version,
407                        data_base64: base64::engine::general_purpose::STANDARD.encode(&entry.data),
408                    },
409                );
410            }
411
412            // Write to temp file first (atomic pattern)
413            let tmp_path = self.temp_path();
414            {
415                let file = File::create(&tmp_path)?;
416                let mut writer = BufWriter::new(file);
417                serde_json::to_writer_pretty(&mut writer, &state_file).map_err(|e| {
418                    StorageError::Serialization(format!("failed to serialize state: {e}"))
419                })?;
420                writer.flush()?;
421                writer.get_ref().sync_all()?;
422            }
423
424            // Atomic rename
425            fs::rename(&tmp_path, &self.path)?;
426
427            tracing::debug!(
428                path = %self.path.display(),
429                entries = entries.len(),
430                "saved widget state"
431            );
432
433            Ok(())
434        }
435
436        fn clear(&self) -> StorageResult<()> {
437            if self.path.exists() {
438                fs::remove_file(&self.path)?;
439            }
440            Ok(())
441        }
442
443        fn is_available(&self) -> bool {
444            // Check if we can write to the directory
445            if let Some(parent) = self.path.parent() {
446                if !parent.exists() {
447                    return std::fs::create_dir_all(parent).is_ok();
448                }
449                // Check write permission (try to create temp file)
450                let test_path = parent.join(".ftui_test_write");
451                if std::fs::write(&test_path, b"test").is_ok() {
452                    let _ = std::fs::remove_file(&test_path);
453                    return true;
454                }
455            }
456            false
457        }
458    }
459
460    impl fmt::Debug for FileStorage {
461        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
462            f.debug_struct("FileStorage")
463                .field("path", &self.path)
464                .finish()
465        }
466    }
467}
468
469#[cfg(feature = "state-persistence")]
470pub use file_storage::FileStorage;
471
472// ─────────────────────────────────────────────────────────────────────────────
473// State Registry
474// ─────────────────────────────────────────────────────────────────────────────
475
476/// Central registry for widget state persistence.
477///
478/// The registry maintains an in-memory cache of widget states and delegates
479/// to a [`StorageBackend`] for persistence. It provides the main API for
480/// save/restore operations.
481///
482/// # Thread Safety
483///
484/// The registry is `Send + Sync` and uses internal locking for thread-safe access.
485///
486/// # Example
487///
488/// ```ignore
489/// use ftui_runtime::state_persistence::{StateRegistry, MemoryStorage};
490///
491/// // Create registry with memory storage
492/// let registry = StateRegistry::new(Box::new(MemoryStorage::new()));
493///
494/// // Load state for a widget
495/// if let Some(entry) = registry.get("ScrollView::main") {
496///     // Deserialize and restore...
497/// }
498///
499/// // Save state
500/// registry.set("ScrollView::main", 1, serialized_data);
501/// registry.flush()?;
502/// ```
503pub struct StateRegistry {
504    backend: Box<dyn StorageBackend>,
505    cache: RwLock<HashMap<String, StoredEntry>>,
506    dirty: RwLock<bool>,
507}
508
509impl StateRegistry {
510    /// Create a new registry with the given storage backend.
511    ///
512    /// Does not automatically load from storage; call [`load`](Self::load) first.
513    #[must_use]
514    pub fn new(backend: Box<dyn StorageBackend>) -> Self {
515        Self {
516            backend,
517            cache: RwLock::new(HashMap::new()),
518            dirty: RwLock::new(false),
519        }
520    }
521
522    /// Create a registry with memory storage (ephemeral, for testing).
523    #[must_use]
524    pub fn in_memory() -> Self {
525        Self::new(Box::new(MemoryStorage::new()))
526    }
527
528    /// Create a registry with file storage at the given path.
529    #[cfg(feature = "state-persistence")]
530    #[must_use]
531    pub fn with_file(path: impl AsRef<std::path::Path>) -> Self {
532        Self::new(Box::new(FileStorage::new(path)))
533    }
534
535    /// Load all state from the storage backend.
536    ///
537    /// This replaces the in-memory cache with stored data.
538    /// Safe to call multiple times; later calls refresh the cache.
539    pub fn load(&self) -> StorageResult<usize> {
540        let entries = self.backend.load_all()?;
541        let count = entries.len();
542
543        let mut cache = self
544            .cache
545            .write()
546            .map_err(|_| StorageError::Corruption("cache lock poisoned".into()))?;
547        *cache = entries;
548
549        let mut dirty = self
550            .dirty
551            .write()
552            .map_err(|_| StorageError::Corruption("dirty lock poisoned".into()))?;
553        *dirty = false;
554
555        tracing::debug!(backend = %self.backend.name(), count, "loaded widget state");
556        Ok(count)
557    }
558
559    /// Flush dirty state to the storage backend.
560    ///
561    /// Only writes if changes have been made since last flush.
562    /// Returns `Ok(true)` if data was written, `Ok(false)` if no changes.
563    pub fn flush(&self) -> StorageResult<bool> {
564        let dirty = {
565            let guard = self
566                .dirty
567                .read()
568                .map_err(|_| StorageError::Corruption("dirty lock poisoned".into()))?;
569            *guard
570        };
571
572        if !dirty {
573            return Ok(false);
574        }
575
576        let cache = self
577            .cache
578            .read()
579            .map_err(|_| StorageError::Corruption("cache lock poisoned".into()))?;
580
581        self.backend.save_all(&cache)?;
582
583        let mut dirty_guard = self
584            .dirty
585            .write()
586            .map_err(|_| StorageError::Corruption("dirty lock poisoned".into()))?;
587        *dirty_guard = false;
588
589        Ok(true)
590    }
591
592    /// Get a stored state entry by canonical key.
593    ///
594    /// Returns `None` if no state exists for the key.
595    #[must_use]
596    pub fn get(&self, key: &str) -> Option<StoredEntry> {
597        let cache = self.cache.read().ok()?;
598        cache.get(key).cloned()
599    }
600
601    /// Set a state entry.
602    ///
603    /// Marks the registry as dirty; call [`flush`](Self::flush) to persist.
604    pub fn set(&self, key: impl Into<String>, version: u32, data: Vec<u8>) {
605        let key = key.into();
606        if let Ok(mut cache) = self.cache.write() {
607            cache.insert(key.clone(), StoredEntry { key, version, data });
608            if let Ok(mut dirty) = self.dirty.write() {
609                *dirty = true;
610            }
611        }
612    }
613
614    /// Remove a state entry.
615    ///
616    /// Returns the removed entry if it existed.
617    pub fn remove(&self, key: &str) -> Option<StoredEntry> {
618        let result = self.cache.write().ok()?.remove(key);
619        if result.is_some()
620            && let Ok(mut dirty) = self.dirty.write()
621        {
622            *dirty = true;
623        }
624        result
625    }
626
627    /// Clear all state from both cache and storage.
628    pub fn clear(&self) -> StorageResult<()> {
629        self.backend.clear()?;
630        if let Ok(mut cache) = self.cache.write() {
631            cache.clear();
632        }
633        if let Ok(mut dirty) = self.dirty.write() {
634            *dirty = false;
635        }
636        Ok(())
637    }
638
639    /// Get the number of cached entries.
640    #[must_use]
641    pub fn len(&self) -> usize {
642        self.cache.read().map(|c| c.len()).unwrap_or(0)
643    }
644
645    /// Check if the cache is empty.
646    #[must_use]
647    pub fn is_empty(&self) -> bool {
648        self.len() == 0
649    }
650
651    /// Check if there are unsaved changes.
652    #[must_use]
653    pub fn is_dirty(&self) -> bool {
654        self.dirty.read().map(|d| *d).unwrap_or(false)
655    }
656
657    /// Get the backend name for logging.
658    #[must_use]
659    pub fn backend_name(&self) -> &str {
660        self.backend.name()
661    }
662
663    /// Check if the storage backend is available.
664    #[must_use]
665    pub fn is_available(&self) -> bool {
666        self.backend.is_available()
667    }
668
669    /// Get all cached keys.
670    #[must_use]
671    pub fn keys(&self) -> Vec<String> {
672        self.cache
673            .read()
674            .map(|c| c.keys().cloned().collect())
675            .unwrap_or_default()
676    }
677}
678
679impl fmt::Debug for StateRegistry {
680    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
681        f.debug_struct("StateRegistry")
682            .field("backend", &self.backend.name())
683            .field("entries", &self.len())
684            .field("dirty", &self.is_dirty())
685            .finish()
686    }
687}
688
689// Make it Arc-able for shared ownership
690impl StateRegistry {
691    /// Wrap in Arc for shared ownership.
692    #[must_use]
693    pub fn shared(self) -> Arc<Self> {
694        Arc::new(self)
695    }
696}
697
698// ─────────────────────────────────────────────────────────────────────────────
699// Statistics and Diagnostics
700// ─────────────────────────────────────────────────────────────────────────────
701
702/// Statistics about the state registry.
703#[derive(Clone, Debug, Default)]
704pub struct RegistryStats {
705    /// Number of entries in cache.
706    pub entry_count: usize,
707    /// Total bytes of state data.
708    pub total_bytes: usize,
709    /// Whether there are unsaved changes.
710    pub dirty: bool,
711    /// Backend name.
712    pub backend: String,
713}
714
715impl StateRegistry {
716    /// Get statistics about the registry.
717    #[must_use]
718    pub fn stats(&self) -> RegistryStats {
719        let (entry_count, total_bytes) = self
720            .cache
721            .read()
722            .map(|c| {
723                let count = c.len();
724                let bytes: usize = c.values().map(|e| e.data.len()).sum();
725                (count, bytes)
726            })
727            .unwrap_or((0, 0));
728
729        RegistryStats {
730            entry_count,
731            total_bytes,
732            dirty: self.is_dirty(),
733            backend: self.backend.name().to_string(),
734        }
735    }
736}
737
738// ─────────────────────────────────────────────────────────────────────────────
739// Tests
740// ─────────────────────────────────────────────────────────────────────────────
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745
746    #[test]
747    fn memory_storage_basic_operations() {
748        let storage = MemoryStorage::new();
749
750        // Initially empty
751        let entries = storage.load_all().unwrap();
752        assert!(entries.is_empty());
753
754        // Save some entries
755        let mut data = HashMap::new();
756        data.insert(
757            "key1".to_string(),
758            StoredEntry {
759                key: "key1".to_string(),
760                version: 1,
761                data: b"hello".to_vec(),
762            },
763        );
764        storage.save_all(&data).unwrap();
765
766        // Load back
767        let loaded = storage.load_all().unwrap();
768        assert_eq!(loaded.len(), 1);
769        assert_eq!(loaded["key1"].data, b"hello");
770
771        // Clear
772        storage.clear().unwrap();
773        assert!(storage.load_all().unwrap().is_empty());
774    }
775
776    #[test]
777    fn memory_storage_with_entries() {
778        let mut entries = HashMap::new();
779        entries.insert(
780            "test".to_string(),
781            StoredEntry {
782                key: "test".to_string(),
783                version: 2,
784                data: vec![1, 2, 3],
785            },
786        );
787        let storage = MemoryStorage::with_entries(entries);
788
789        let loaded = storage.load_all().unwrap();
790        assert_eq!(loaded.len(), 1);
791        assert_eq!(loaded["test"].version, 2);
792    }
793
794    #[test]
795    fn registry_basic_operations() {
796        let registry = StateRegistry::in_memory();
797
798        // Initially empty
799        assert!(registry.is_empty());
800        assert!(!registry.is_dirty());
801
802        // Set an entry
803        registry.set("widget::1", 1, b"data".to_vec());
804        assert_eq!(registry.len(), 1);
805        assert!(registry.is_dirty());
806
807        // Get the entry
808        let entry = registry.get("widget::1").unwrap();
809        assert_eq!(entry.version, 1);
810        assert_eq!(entry.data, b"data");
811
812        // Get non-existent
813        assert!(registry.get("widget::99").is_none());
814
815        // Flush
816        assert!(registry.flush().unwrap());
817        assert!(!registry.is_dirty());
818
819        // No-op flush when clean
820        assert!(!registry.flush().unwrap());
821
822        // Remove
823        let removed = registry.remove("widget::1").unwrap();
824        assert_eq!(removed.data, b"data");
825        assert!(registry.is_empty());
826        assert!(registry.is_dirty());
827    }
828
829    #[test]
830    fn registry_load_and_flush() {
831        let storage = MemoryStorage::new();
832        let mut initial = HashMap::new();
833        initial.insert(
834            "pre::existing".to_string(),
835            StoredEntry {
836                key: "pre::existing".to_string(),
837                version: 5,
838                data: b"old".to_vec(),
839            },
840        );
841        storage.save_all(&initial).unwrap();
842
843        let registry = StateRegistry::new(Box::new(storage));
844
845        // Load existing data
846        let count = registry.load().unwrap();
847        assert_eq!(count, 1);
848        assert!(!registry.is_dirty());
849
850        let entry = registry.get("pre::existing").unwrap();
851        assert_eq!(entry.version, 5);
852    }
853
854    #[test]
855    fn registry_clear() {
856        let registry = StateRegistry::in_memory();
857        registry.set("a", 1, vec![]);
858        registry.set("b", 1, vec![]);
859        assert_eq!(registry.len(), 2);
860
861        registry.clear().unwrap();
862        assert!(registry.is_empty());
863        assert!(!registry.is_dirty());
864    }
865
866    #[test]
867    fn registry_keys() {
868        let registry = StateRegistry::in_memory();
869        registry.set("widget::a", 1, vec![]);
870        registry.set("widget::b", 1, vec![]);
871
872        let mut keys = registry.keys();
873        keys.sort();
874        assert_eq!(keys, vec!["widget::a", "widget::b"]);
875    }
876
877    #[test]
878    fn registry_stats() {
879        let registry = StateRegistry::in_memory();
880        registry.set("x", 1, vec![1, 2, 3, 4, 5]);
881        registry.set("y", 1, vec![6, 7, 8]);
882
883        let stats = registry.stats();
884        assert_eq!(stats.entry_count, 2);
885        assert_eq!(stats.total_bytes, 8);
886        assert!(stats.dirty);
887        assert_eq!(stats.backend, "MemoryStorage");
888    }
889
890    #[test]
891    fn registry_shared() {
892        let registry = StateRegistry::in_memory().shared();
893        registry.set("test", 1, vec![42]);
894
895        let registry2 = Arc::clone(&registry);
896        assert_eq!(registry2.get("test").unwrap().data, vec![42]);
897    }
898
899    #[test]
900    fn storage_error_display() {
901        let io_err = StorageError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "missing"));
902        assert!(io_err.to_string().contains("I/O error"));
903
904        let corrupt = StorageError::Corruption("bad data".into());
905        assert!(corrupt.to_string().contains("corruption"));
906
907        let unavail = StorageError::Unavailable("no backend".into());
908        assert!(unavail.to_string().contains("unavailable"));
909    }
910}
911
912#[cfg(all(test, feature = "state-persistence"))]
913mod file_storage_tests {
914    use super::*;
915    use std::io::Write;
916    use tempfile::TempDir;
917
918    #[test]
919    fn file_storage_round_trip() {
920        let tmp = TempDir::new().unwrap();
921        let path = tmp.path().join("state.json");
922        let storage = FileStorage::new(&path);
923
924        // Save
925        let mut entries = HashMap::new();
926        entries.insert(
927            "widget::test".to_string(),
928            StoredEntry {
929                key: "widget::test".to_string(),
930                version: 3,
931                data: b"hello world".to_vec(),
932            },
933        );
934        storage.save_all(&entries).unwrap();
935
936        // File should exist
937        assert!(path.exists());
938
939        // Load back
940        let loaded = storage.load_all().unwrap();
941        assert_eq!(loaded.len(), 1);
942        assert_eq!(loaded["widget::test"].version, 3);
943        assert_eq!(loaded["widget::test"].data, b"hello world");
944    }
945
946    #[test]
947    fn file_storage_load_nonexistent() {
948        let tmp = TempDir::new().unwrap();
949        let path = tmp.path().join("does_not_exist.json");
950        let storage = FileStorage::new(&path);
951
952        let entries = storage.load_all().unwrap();
953        assert!(entries.is_empty());
954    }
955
956    #[test]
957    fn file_storage_clear() {
958        let tmp = TempDir::new().unwrap();
959        let path = tmp.path().join("state.json");
960
961        // Create file
962        std::fs::write(&path, "{}").unwrap();
963        assert!(path.exists());
964
965        let storage = FileStorage::new(&path);
966        storage.clear().unwrap();
967        assert!(!path.exists());
968    }
969
970    #[test]
971    fn file_storage_creates_parent_dirs() {
972        let tmp = TempDir::new().unwrap();
973        let path = tmp.path().join("nested").join("dirs").join("state.json");
974        let storage = FileStorage::new(&path);
975
976        let mut entries = HashMap::new();
977        entries.insert(
978            "k".to_string(),
979            StoredEntry {
980                key: "k".to_string(),
981                version: 1,
982                data: vec![],
983            },
984        );
985        storage.save_all(&entries).unwrap();
986        assert!(path.exists());
987    }
988
989    #[test]
990    fn file_storage_handles_corrupt_entry() {
991        let tmp = TempDir::new().unwrap();
992        let path = tmp.path().join("state.json");
993
994        // Write valid JSON but with invalid base64
995        let mut f = std::fs::File::create(&path).unwrap();
996        writeln!(
997            f,
998            r#"{{"format_version":1,"entries":{{"bad":{{"version":1,"data_base64":"!!invalid!!"}},"good":{{"version":1,"data_base64":"aGVsbG8="}}}}}}"#
999        )
1000        .unwrap();
1001
1002        let storage = FileStorage::new(&path);
1003        let loaded = storage.load_all().unwrap();
1004
1005        // Bad entry skipped, good entry loaded
1006        assert_eq!(loaded.len(), 1);
1007        assert!(loaded.contains_key("good"));
1008        assert_eq!(loaded["good"].data, b"hello");
1009    }
1010}