easy_prefs/
lib.rs

1//! # easy_prefs
2//!
3//! A simple, safe, and performant preferences library for Rust applications.
4//!
5//! Created by Ever Accountable – an app that helps people overcome compulsive porn use
6//! and become their best selves. Visit [everaccountable.com](https://everaccountable.com) for more details.
7//!
8//! This library provides an intuitive API for managing preferences using a struct-like interface.
9//! Its key design goals are:
10//!
11//! - **Ease of Use**: Read/write preferences as easily as struct fields.
12//! - **Safety**: Uses temporary files for writes to prevent data corruption.
13//! - **Performance**: Optimized for fast operations.
14//! - **Testability**: Integrates seamlessly with unit tests.
15//! - **Cross-Platform**: Works on native platforms and WebAssembly (WASM).
16//!
17//! **Limitation**: Not suited for large datasets. All data is held in memory, and the entire file
18//! is rewritten on save. For substantial data, use a database instead.
19//!
20//! ## Single-Instance Constraint
21//!
22//! The `load()` method enforces that only one instance of a preferences struct exists at a time,
23//! using a static atomic flag. This prevents data races in production but can cause issues in
24//! parallel test execution. Tests using `load()` are combined into a single test to avoid conflicts.
25//!
26//! ## Error Handling
27//!
28//! The library provides two loading methods:
29//! - `load()` - Always succeeds by returning defaults on errors (panics in debug mode)
30//! - `load_with_error()` - Returns `Result<Self, LoadError>` for explicit error handling
31//!
32//! ## WASM Support
33//!
34//! This library supports WebAssembly targets for use in browser extensions and web applications.
35//! When compiled to WASM, preferences are stored in localStorage instead of the file system.
36
37pub mod storage;
38
39// Re-export dependencies for convenience
40pub use once_cell;
41pub use paste; // Macro utilities
42pub use toml; // TOML serialization
43pub use web_time; // Cross-platform time implementation
44
45/// Errors that can occur when loading preferences.
46#[derive(Debug)]
47pub enum LoadError {
48    /// Another instance is already loaded (due to single-instance constraint).
49    InstanceAlreadyLoaded,
50    /// Failed to deserialize TOML data.
51    DeserializationError(String, toml::de::Error),
52    /// Storage operation failed
53    StorageError(std::io::Error),
54}
55
56impl std::fmt::Display for LoadError {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        match self {
59            Self::InstanceAlreadyLoaded => {
60                write!(f, "another preferences instance is already loaded")
61            }
62            Self::DeserializationError(location, e) => {
63                write!(f, "deserialization error: {e} at {location}")
64            }
65            Self::StorageError(e) => write!(f, "storage error: {e}"),
66        }
67    }
68}
69
70impl std::error::Error for LoadError {}
71/// Macro to define a preferences struct with persistence.
72///
73/// Generates a struct with methods for loading, saving, and editing preferences.
74/// Enforces a single instance (except in test mode) using a static flag.
75///
76/// # Example
77///
78/// ```rust
79/// use easy_prefs::easy_prefs;
80///
81/// easy_prefs! {
82///     pub struct AppPrefs {
83///         pub dark_mode: bool = false => "dark_mode",
84///         pub font_size: i32 = 14 => "font_size",
85///     },
86///     "app-settings"
87/// }
88/// ```
89///
90/// # Platform Behavior
91///
92/// - **Native**: Stores preferences as TOML files in the specified directory
93/// - **WASM**: Stores preferences in browser localStorage
94#[macro_export]
95macro_rules! easy_prefs {
96    (
97        $(#[$outer:meta])*
98        $vis:vis struct $name:ident {
99            $(
100                $(#[$inner:meta])*
101                $field_vis:vis $field:ident: $type:ty = $default:expr => $saved_name:expr,
102            )*
103        },
104        $preferences_filename:expr
105    ) => {
106        $crate::paste::paste!{
107            // Static flag to enforce single instance.
108            static [<$name:upper _INSTANCE_EXISTS>]: $crate::once_cell::sync::Lazy<std::sync::atomic::AtomicBool> =
109                $crate::once_cell::sync::Lazy::new(|| std::sync::atomic::AtomicBool::new(false));
110
111            // Guard that resets the instance flag on drop.
112            #[derive(Debug)]
113            struct [<$name InstanceGuard>];
114            impl Drop for [<$name InstanceGuard>] {
115                fn drop(&mut self) {
116                    [<$name:upper _INSTANCE_EXISTS>].store(false, std::sync::atomic::Ordering::Release);
117                }
118            }
119
120            $(#[$outer])*
121            #[derive(serde::Serialize, serde::Deserialize, Debug)]
122            #[serde(default)]  // Use defaults for missing fields.
123            $vis struct $name {
124                $(
125                    $(#[$inner])*
126                    #[serde(rename = $saved_name)]
127                    $field_vis [<_ $field>]: $type,
128                )*
129                #[serde(skip_serializing, skip_deserializing)]
130                storage: Option<Box<dyn $crate::storage::Storage>>,
131                #[serde(skip_serializing, skip_deserializing)]
132                storage_key: Option<String>,
133                #[serde(skip_serializing, skip_deserializing)]
134                #[cfg(not(target_arch = "wasm32"))]
135                temp_file: Option<tempfile::NamedTempFile>,
136                #[serde(skip_serializing, skip_deserializing)]
137                _instance_guard: Option<[<$name InstanceGuard>]>,
138            }
139
140            impl Default for $name {
141                fn default() -> Self {
142                    Self {
143                        $( [<_ $field>]: $default, )*
144                        storage: None,
145                        storage_key: None,
146                        #[cfg(not(target_arch = "wasm32"))]
147                        temp_file: None,
148                        _instance_guard: None,
149                    }
150                }
151            }
152
153            impl $name {
154                pub const PREFERENCES_FILENAME: &'static str = concat!($preferences_filename, ".toml");
155
156                /// Loads preferences from a file, gracefully handling errors.
157                ///
158                /// This method provides a simple API that always succeeds:
159                /// - In release builds: Returns defaults on errors (except instance conflicts)
160                /// - In debug/test builds: Panics on errors to catch issues early
161                /// - Always panics if another instance is already loaded
162                ///
163                /// For explicit error handling, use `load_with_error()` instead.
164                ///
165                /// # Arguments
166                ///
167                /// * `directory` - The directory path (native) or app ID (WASM) where preferences are stored.
168                ///
169                /// # Panics
170                ///
171                /// - Always panics if another instance is already loaded
172                /// - In debug/test builds only: panics on storage or deserialization errors
173                pub fn load(directory: &str) -> Self {
174                    match Self::load_with_error(directory) {
175                        Ok(prefs) => prefs,
176                        Err(e) => {
177                            // Always panic if another instance exists - this is a programming error
178                            if matches!(e, $crate::LoadError::InstanceAlreadyLoaded) {
179                                panic!("Failed to load preferences: {}", e);
180                            }
181                            
182                            #[cfg(any(debug_assertions, test))]
183                            {
184                                // Panic in debug/test to catch issues early
185                                panic!("Failed to load preferences: {}", e);
186                            }
187                            
188                            #[cfg(not(any(debug_assertions, test)))]
189                            {
190                                // In production, log the error and return defaults
191                                eprintln!("Failed to load preferences from {}: {}, using defaults", directory, e);
192                                
193                                // We need to acquire the instance guard for the default instance
194                                // First, try to acquire it
195                                let was_free = [<$name:upper _INSTANCE_EXISTS>].compare_exchange(
196                                    false, true, std::sync::atomic::Ordering::Acquire, std::sync::atomic::Ordering::Relaxed
197                                );
198                                
199                                if was_free.is_err() {
200                                    // This should be rare - means load_with_error failed but instance still exists
201                                    panic!("Failed to load preferences and instance is still locked: {}", e);
202                                }
203                                
204                                let guard = [<$name InstanceGuard>];
205                                let storage = $crate::storage::create_storage(directory);
206                                let storage_key = Self::PREFERENCES_FILENAME;
207                                
208                                let mut cfg = Self::default();
209                                cfg.storage = Some(storage);
210                                cfg.storage_key = Some(storage_key.to_string());
211                                cfg._instance_guard = Some(guard);
212                                cfg
213                            }
214                        }
215                    }
216                }
217
218                /// Loads preferences from a file with explicit error handling.
219                ///
220                /// Deserializes from file if it exists; otherwise uses defaults.
221                /// Only one instance can exist at a time (tracked by a static flag).
222                ///
223                /// # Arguments
224                ///
225                /// * `directory` - The directory path (native) or app ID (WASM) where preferences are stored.
226                ///
227                /// # Errors
228                ///
229                /// Returns a `LoadError` if:
230                /// - Another instance is already loaded.
231                /// - Storage operations fail.
232                /// - TOML deserialization fails.
233                pub fn load_with_error(directory: &str) -> Result<Self, $crate::LoadError> {
234
235                    {
236                        // Runtime duplicate check for field_names. We don't want duplicates!
237                        use std::collections::HashSet;
238                        let keys = [ $( ($saved_name, stringify!($field) ), )* ];
239                        let mut seen = HashSet::new();
240                        for (key, field_name) in keys.iter() {
241                            if !seen.insert(*key) {
242                                panic!("Duplicate saved_name '{}' found for field '{}'", key, field_name);
243                            }
244                        }
245                    }
246
247                    let was_free = [<$name:upper _INSTANCE_EXISTS>].compare_exchange(
248                        false, true, std::sync::atomic::Ordering::Acquire, std::sync::atomic::Ordering::Relaxed
249                    );
250                    if was_free.is_err() {
251                        return Err($crate::LoadError::InstanceAlreadyLoaded);
252                    }
253
254                    let guard = [<$name InstanceGuard>];
255                    let storage = $crate::storage::create_storage(directory);
256                    let storage_key = Self::PREFERENCES_FILENAME;
257
258                    let mut cfg = match storage.read(storage_key).map_err($crate::LoadError::StorageError)? {
259                        Some(contents) => {
260                            $crate::toml::from_str::<Self>(&contents)
261                                .map_err(|e| $crate::LoadError::DeserializationError(
262                                    storage.get_path(storage_key), e
263                                ))?
264                        }
265                        None => Self::default(),
266                    };
267
268                    cfg.storage = Some(storage);
269                    cfg.storage_key = Some(storage_key.to_string());
270                    cfg._instance_guard = Some(guard);
271                    Ok(cfg)
272                }
273
274                /// DEPRECATED: This method is no longer supported.
275                ///
276                /// # Why was this removed?
277                ///
278                /// `load_default()` bypassed the single-instance constraint, which could lead to:
279                /// - Data corruption when multiple instances write to the same file
280                /// - Race conditions in concurrent environments
281                /// - Inconsistent application state
282                ///
283                /// # What to use instead?
284                ///
285                /// Use `load()` which handles errors gracefully:
286                /// - In debug/test: Helps catch configuration issues early
287                /// - In production: Falls back to defaults when needed
288                ///
289                /// If you need explicit error handling, use `load_with_error()`.
290                ///
291                /// # Panics
292                ///
293                /// Always panics with a deprecation message.
294                #[deprecated(
295                    since = "3.0.0",
296                    note = "Use `load()` instead - it handles errors gracefully without compromising safety"
297                )]
298                pub fn load_default(_directory_or_app_id: &str) -> Self {
299                    panic!(
300                        "load_default() has been removed in version 3.0.0 because it bypassed safety constraints. \
301                        Use load() instead, which handles errors gracefully while maintaining the single-instance guarantee. \
302                        See the documentation for more details."
303                    );
304                }
305
306                /// Loads preferences into a temporary location for testing (ignores the single-instance constraint).
307                #[cfg(not(target_arch = "wasm32"))]
308                pub fn load_testing() -> Self {
309                    let tmp_file = tempfile::NamedTempFile::with_prefix(Self::PREFERENCES_FILENAME)
310                        .expect("Failed to create temporary file for testing preferences");
311                    let tmp_dir = tmp_file.path().parent().unwrap().to_str().unwrap();
312                    let storage = $crate::storage::create_storage(tmp_dir);
313                    let storage_key = tmp_file.path().file_name().unwrap().to_str().unwrap();
314
315                    let mut cfg = Self::default();
316                    let serialized = $crate::toml::to_string(&cfg).unwrap();
317                    storage.write(storage_key, &serialized)
318                        .expect("Failed to write preferences data to temporary file");
319
320                    cfg.storage = Some(storage);
321                    cfg.storage_key = Some(storage_key.to_string());
322                    cfg.temp_file = Some(tmp_file);
323                    cfg
324                }
325
326                /// Loads preferences into a temporary location for testing (ignores the single-instance constraint).
327                #[cfg(target_arch = "wasm32")]
328                pub fn load_testing() -> Self {
329                    let test_id = format!("test_{}", $crate::web_time::SystemTime::now()
330                        .duration_since($crate::web_time::UNIX_EPOCH)
331                        .unwrap()
332                        .as_millis());
333                    let storage = $crate::storage::create_storage(&test_id);
334                    let storage_key = Self::PREFERENCES_FILENAME;
335
336                    let mut cfg = Self::default();
337                    cfg.storage = Some(storage);
338                    cfg.storage_key = Some(storage_key.to_string());
339                    cfg
340                }
341
342                /// Serializes preferences to a TOML string.
343                pub fn to_string(&self) -> String {
344                    $crate::toml::to_string(self).expect("Serialization failed")
345                }
346
347                /// Save the preferences data to storage.
348                ///
349                /// This function serializes the preferences data to TOML format and writes it to storage.
350                /// On native platforms, it uses atomic writes via temporary files. On WASM, it writes to localStorage.
351                ///
352                /// # Errors
353                ///
354                /// Returns an error if:
355                /// - Storage is not initialized
356                /// - Serialization fails
357                /// - Storage write operation fails
358                pub fn save(&self) -> Result<(), std::io::Error> {
359                    // Ensure storage is initialized
360                    let storage = self.storage.as_ref().ok_or_else(|| std::io::Error::new(
361                        std::io::ErrorKind::Other,
362                        "storage not initialized"
363                    ))?;
364
365                    let storage_key = self.storage_key.as_ref().ok_or_else(|| std::io::Error::new(
366                        std::io::ErrorKind::Other,
367                        "storage key not set"
368                    ))?;
369
370                    // Serialize the preferences data to TOML
371                    let serialized = $crate::toml::to_string(self).map_err(|e| std::io::Error::new(
372                        std::io::ErrorKind::Other,
373                        format!("serialization failed: {}", e)
374                    ))?;
375
376                    // Write to storage
377                    storage.write(storage_key, &serialized)?;
378
379                    Ok(())
380                }
381
382                /// Returns the storage path/key as a string.
383                pub fn get_preferences_file_path(&self) -> String {
384                    match (&self.storage, &self.storage_key) {
385                        (Some(storage), Some(key)) => storage.get_path(key),
386                        _ => panic!("storage not initialized"),
387                    }
388                }
389
390                $(
391                    /// Gets the value of the field.
392                    pub fn [<get_ $field>](&self) -> &$type {
393                        &self.[<_ $field>]
394                    }
395
396                    /// Sets the field's value and immediately saves.
397                    pub fn [<save_ $field>](&mut self, value: $type) -> Result<(), std::io::Error> {
398                        if self.[<_ $field>] != value {
399                            self.[<_ $field>] = value;
400                            self.save()
401                        } else {
402                            Ok(())
403                        }
404                    }
405                )*
406
407                /// Creates an edit guard for batching updates (saves on drop).
408                pub fn edit(&mut self) -> [<$name EditGuard>] {
409                    [<$name EditGuard>] {
410                        preferences: self,
411                        modified: false,
412                        created: $crate::web_time::Instant::now()
413                    }
414                }
415            }
416
417            /// Guard for batch editing; saves changes on drop if any fields were modified.
418            $vis struct [<$name EditGuard>]<'a> {
419                preferences: &'a mut $name,
420                modified: bool,
421                created: $crate::web_time::Instant,
422            }
423
424            impl<'a> [<$name EditGuard>]<'a> {
425                $(
426                    /// Sets the field's value (save is deferred until the guard is dropped).
427                    pub fn [<set_ $field>](&mut self, value: $type) {
428                        if self.preferences.[<_ $field>] != value {
429                            self.preferences.[<_ $field>] = value;
430                            self.modified = true;
431                        }
432                    }
433
434                    /// Gets the current value of the field.
435                    pub fn [<get_ $field>](&self) -> &$type {
436                        &self.preferences.[<_ $field>]
437                    }
438                )*
439            }
440
441            impl<'a> Drop for [<$name EditGuard>]<'a> {
442                fn drop(&mut self) {
443                    if cfg!(debug_assertions) && !std::thread::panicking() {
444                        let duration = self.created.elapsed();
445                        // Warn if edit guard is held for more than 1 second in debug mode
446                        if duration.as_secs() >= 1 {
447                            eprintln!("Warning: Edit guard held for {:?} - consider reducing the scope", duration);
448                        }
449                    }
450                    if self.modified {
451                        if let Err(e) = self.preferences.save() {
452                            eprintln!("Failed to save: {}", e);
453                        }
454                    }
455                }
456            }
457        }
458    }
459}
460
461#[allow(dead_code)]
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use std::sync::{Arc, Barrier, Mutex};
466    use std::thread;
467    use web_time::Duration;
468
469    #[cfg(debug_assertions)]
470    easy_prefs! {
471        /// Original test preferences.
472        struct TestEasyPreferences {
473            pub bool1_default_true: bool = true => "bool1_default_true",
474            pub bool2_default_true: bool = true => "bool2_default_true",
475            pub bool3_initial_default_false: bool = false => "bool3_initial_default_false",
476            pub string1: String = String::new() => "string1",
477            pub int1: i32 = 42 => "int1",
478        }, "test-easy-prefs"
479    }
480
481    #[cfg(debug_assertions)]
482    easy_prefs! {
483        /// Updated test preferences for schema evolution.
484        pub struct TestEasyPreferencesUpdated {
485            pub bool2_default_true_renamed: bool = true => "bool2_default_true",
486            pub bool3_initial_default_false: bool = true => "bool3_initial_default_false",
487            pub bool4_default_true: bool = true => "bool4_default_true",
488            pub string1: String = "ea".to_string() => "string1",
489            pub string2: String = "new default value".to_string() => "string2",
490        }, "test-easy-prefs"
491    }
492
493    /// Tests loading and saving using `load_testing()` (ignores the single-instance constraint).
494    #[test]
495    fn test_load_save_preferences_with_macro() {
496        let mut prefs = TestEasyPreferences::load_testing();
497        assert_eq!(prefs.get_bool1_default_true(), &true);
498        assert_eq!(prefs.get_int1(), &42);
499
500        prefs
501            .save_bool1_default_true(false)
502            .expect("Failed to save bool1");
503        prefs
504            .save_string1("hi".to_string())
505            .expect("Failed to save string1");
506
507        // Verify the values were saved
508        let file_path = prefs.get_preferences_file_path();
509        assert!(file_path.contains("test-easy-prefs"));
510        // For native platforms, we can verify the file contents
511        #[cfg(not(target_arch = "wasm32"))]
512        {
513            let contents = std::fs::read_to_string(&file_path).expect("Failed to read file");
514            assert!(contents.contains("bool1_default_true = false"));
515            assert!(contents.contains("string1 = \"hi\""));
516        }
517    }
518
519    /// Tests the edit guard batching and save-on-drop functionality.
520    #[test]
521    fn test_edit_guard() {
522        let mut prefs = TestEasyPreferences::load_testing();
523        {
524            let mut guard = prefs.edit();
525            guard.set_bool1_default_true(false);
526            guard.set_int1(43);
527        }
528        assert_eq!(prefs.get_bool1_default_true(), &false);
529        assert_eq!(prefs.get_int1(), &43);
530
531        // Verify the values were saved
532        #[cfg(not(target_arch = "wasm32"))]
533        {
534            let contents = std::fs::read_to_string(prefs.get_preferences_file_path())
535                .expect("Failed to read file");
536            assert!(contents.contains("bool1_default_true = false"));
537            assert!(contents.contains("int1 = 43"));
538        }
539    }
540
541    /// Tests multithreading with Arc/Mutex using `load_testing()`.
542    #[test]
543    fn test_with_arc_mutex() {
544        let prefs = Arc::new(Mutex::new(TestEasyPreferences::load_testing()));
545        {
546            let prefs = prefs.lock().unwrap();
547            assert_eq!(prefs.get_int1(), &42);
548        }
549        {
550            let mut prefs = prefs.lock().unwrap();
551            prefs.save_int1(100).expect("Failed to save int1");
552        }
553        {
554            let prefs = prefs.lock().unwrap();
555            assert_eq!(prefs.get_int1(), &100);
556        }
557    }
558
559    /// Combined test for real file operations and the single-instance constraint.
560    ///
561    /// Running these tests sequentially avoids conflicts caused by the single-instance flag.
562    #[test]
563    fn test_real_preferences_and_single_instance() {
564        // --- Part 1: Test persistence and schema upgrades ---
565        {
566            let path = {
567                let prefs = TestEasyPreferences::load("/tmp/tests/");
568                prefs.get_preferences_file_path()
569            };
570            let _ = std::fs::remove_file(&path); // Clean up any previous run
571
572            // Save some values.
573            {
574                let mut prefs = TestEasyPreferences::load("/tmp/tests/");
575                prefs
576                    .save_bool1_default_true(false)
577                    .expect("Failed to save bool1");
578                prefs.edit().set_string1("test1".to_string());
579            }
580            // Verify persistence.
581            {
582                let prefs = TestEasyPreferences::load("/tmp/tests/");
583                assert_eq!(prefs.get_bool1_default_true(), &false);
584                assert_eq!(prefs.get_string1(), "test1");
585            }
586            // Test schema evolution.
587            {
588                let prefs = TestEasyPreferencesUpdated::load("/tmp/tests/");
589                assert_eq!(prefs.get_bool2_default_true_renamed(), &true); // Default (not saved earlier)
590                assert_eq!(prefs.get_string1(), "test1");
591                assert_eq!(prefs.get_string2(), "new default value");
592            }
593        } // All instances from part 1 are now dropped
594
595        // --- Part 2: Test the single-instance constraint ---
596        let barrier = Arc::new(Barrier::new(2));
597        let barrier_clone = barrier.clone();
598
599        let test_dir = "/tmp/test_instance_conflict/";
600        let handle = thread::spawn(move || {
601            let prefs = TestEasyPreferences::load_with_error(test_dir).expect("Failed to load");
602            barrier_clone.wait(); // Hold instance until main thread tries to load.
603            thread::sleep(Duration::from_millis(100));
604            drop(prefs); // Release instance.
605            true
606        });
607
608        barrier.wait(); // Synchronize with spawned thread.
609        let result = TestEasyPreferences::load_with_error(test_dir);
610        assert!(matches!(result, Err(LoadError::InstanceAlreadyLoaded)));
611
612        handle.join().unwrap(); // Wait for thread to finish.
613
614        // Verify instance can be loaded after release.
615        let _prefs = TestEasyPreferences::load(test_dir);
616
617        // Verify that `load_testing()` ignores the single-instance constraint.
618        let _test1 = TestEasyPreferences::load_testing();
619        let _test2 = TestEasyPreferences::load_testing();
620    }
621
622    /// Test that the new load() API panics on errors in debug mode
623    #[test]
624    #[should_panic(expected = "Failed to load preferences")]
625    #[cfg(debug_assertions)]
626    fn test_load_panics_on_error_in_debug() {
627        let test_dir = "/tmp/tests_panic/";
628        
629        // First load should succeed
630        let _prefs1 = TestEasyPreferences::load(test_dir);
631        
632        // Second load should panic due to instance already loaded
633        let _prefs2 = TestEasyPreferences::load(test_dir);
634    }
635}