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}