easy_prefs 3.0.0

The simplest to use API we could think of to persist prefs to disk. Basically wrap a macro around a struct (see syntax), then data is saved when you write to it. Performant, testable, thread safe, easy to migrate, and careful to not corrupt your data.
Documentation

easy_prefs

A simple, safe, and performant preferences library for Rust applications that makes storing and retrieving settings as easy as reading and writing struct fields.

This macro-based library lets you define your preferences—including default values and custom storage keys—and persist them to disk using TOML. It emphasizes data safety by using atomic writes and enforces a single-instance rule to prevent race conditions.

Now with WebAssembly support! Use the same API in browser extensions, web apps, and native applications. When compiled to WASM, preferences are stored in localStorage instead of the file system.

Created by Ever Accountable – an app dedicated to helping people overcome compulsive porn use and become their best selves. More info at everaccountable.com.

Quick Start

1. Add Dependencies

In your Cargo.toml, add:

[dependencies]
easy_prefs = "3.0"  # Use the latest version
serde = { version = "1.0", features = ["derive"] }

(The library re-exports paste, toml, and once_cell so you don’t need to add them separately.)

2. Define Your Preferences

Create a preferences struct with default values and customizable storage keys:

use easy_prefs::easy_prefs;

easy_prefs! {
    pub struct AppPreferences {
        /// Boolean preference with default `true`, stored as "notifications"
        pub notifications: bool = true => "notifications",
        /// String preference with default "guest", stored as "username"
        pub username: String = "guest".to_string() => "username",
    },
    "app-preferences"  // This defines the filename (app-preferences.toml)
}

3. Load and Use Preferences

fn main() {
    // Load preferences - always succeeds by using defaults if needed
    // In debug builds: panics on errors to catch issues early  
    // In release builds: logs errors and returns defaults
    let mut prefs = AppPreferences::load("/path/to/config/dir");

    println!("Notifications: {}", prefs.get_notifications());

    // Update a value (this write is blocking).
    prefs.save_notifications(false).expect("Save failed");

    // Batch updates using an edit guard (auto-saves on drop).
    {
        let mut guard = prefs.edit();
        guard.set_notifications(true);
        guard.set_username("Abe Lincoln".to_string());
    }
}

WebAssembly Support

easy_prefs works seamlessly in WebAssembly environments like Safari extensions and web applications. When compiled to WASM, it automatically uses localStorage instead of the file system.

Enabling WASM Support

Easy_prefs automatically detects WASM targets, no special features needed:

[dependencies]
easy_prefs = "3.0"

Building for WASM

cargo build --target wasm32-unknown-unknown --features wasm

Usage in Safari Extensions

use easy_prefs::easy_prefs;
use wasm_bindgen::prelude::*;

easy_prefs! {
    pub struct ExtensionSettings {
        pub enabled: bool = true => "enabled",
        pub api_key: String = String::new() => "api_key",
    },
    "safari-extension"
}

#[wasm_bindgen]
pub fn init_extension() -> Result<(), JsValue> {
    // The "directory" parameter becomes the localStorage key prefix
    // Using load_with_error() for explicit error handling in WASM
    let mut settings = ExtensionSettings::load_with_error("com.mycompany.extension")
        .map_err(|e| JsValue::from_str(&e.to_string()))?;
    
    // Use the same API as native
    settings.save_enabled(true)
        .map_err(|e| JsValue::from_str(&e.to_string()))?;
    Ok(())
}

Storage Locations

  • Native platforms: Files stored in the specified directory (directory will be created if it doesn't exist)
  • WASM/Browser: Data stored in localStorage with keys prefixed by your app ID (slashes and dots in the app ID are replaced with underscores)

Detailed Information

Error Handling

The library provides two methods for loading preferences:

  • load() - Simple API that always succeeds:

    • In release builds: Returns defaults on errors (logs them)
    • In debug/test builds: Panics on errors to catch issues early
    • Always panics if another instance is already loaded
    • When returning defaults due to errors, storage is still properly configured for future saves
    • Use this for the simplest API where the app should continue even if preferences can't be loaded
  • load_with_error() - Returns Result<Self, LoadError>:

    • For explicit error handling when needed
    • Returns LoadError enum with these variants:
      • InstanceAlreadyLoaded: Only one instance can be loaded at a time
      • DeserializationError: Errors while parsing TOML data (includes location info)
      • StorageError: General storage operation failures (wraps std::io::Error)

Example:

// Simple approach - always succeeds
let prefs = AppPreferences::load("./config");

// Explicit error handling
match AppPreferences::load_with_error("./config") {
    Ok(prefs) => { /* use prefs */ },
    Err(LoadError::InstanceAlreadyLoaded) => { /* handle conflict */ },
    Err(e) => { /* handle other errors */ }
}

Use Across Threads

Use Arc<Mutex<>> to share the preferences struct between threads. The single-instance constraint prevents loading the same preferences from multiple locations simultaneously - attempting to do so will panic (with load()) or return an error (with load_with_error()).

Atomic Writes

To ensure data integrity, writes are atomic on all platforms:

Native platforms:

  • Data is first written to a temporary file
  • The temporary file is atomically renamed to the final file
  • This ensures the preferences file is never left in a partially written state

WASM/Browser environments:

  • localStorage provides atomic writes by specification
  • The setItem() method either fully succeeds or leaves the old data untouched
  • If the browser crashes or runs out of storage, your existing data remains intact

Testing with load_testing()

For unit tests, use load_testing(), which:

  • Creates a temporary file (cleaned up after the test).
  • Bypasses the single-instance constraint, making testing simpler.

Migration from Version 2.x

Breaking Changes in Version 3.0:

  • load_default() has been removed. It bypassed the single-instance constraint which could lead to data corruption.
  • load() now always succeeds instead of returning a Result. In release builds it returns defaults on errors, in debug builds it panics to catch issues early.
  • For explicit error handling, use the new load_with_error() method which returns Result<Self, LoadError>.

Migration Guide:

// Old (v2.x):
let prefs = AppPreferences::load(dir).unwrap_or_else(|e| {
    log::error!("Failed to load: {}", e);
    AppPreferences::load_default(dir)
});

// New (v3.0) - Simple approach:
let prefs = AppPreferences::load(dir);  // Always succeeds

// New (v3.0) - With explicit error handling:
let prefs = match AppPreferences::load_with_error(dir) {
    Ok(p) => p,
    Err(e) => {
        log::error!("Failed to load: {}", e);
        return; // Handle error appropriately
    }
};

Edit Guards and Debug Checks

When batching updates with an edit guard:

  • A warning (active only in debug builds) ensures the guard isn’t held for more than 1 second to prevent blocking.
  • This safety check helps catch long-held locks during development.

Utility Methods

  • get_preferences_file_path():
    Returns the full path of the preferences file as a string, useful for debugging.

  • load():
    Loads preferences, always succeeding by using defaults if needed. Panics in debug mode on errors to catch issues early.

  • load_with_error():
    Loads preferences with explicit error handling, returning Result<Self, LoadError>.

  • load_testing():
    Creates a temporary instance for unit testing, bypassing the single-instance constraint.

Customizable Storage Keys

The macro’s syntax (=> "field_name") lets you define a stored key that differs from the struct field name. This is helpful when renaming fields or preserving legacy data formats.

Dependencies & Serialization

The macro requires Serde for serialization/deserialization and re-exports helpful crates like paste, toml, once_cell, and web_time to manage lazy statics, code generation, and cross-platform time handling.

Limitations

  • Not for Large Data:
    All data is kept in memory and the entire file is rewritten on every save. Use a full database if you need to handle large datasets.
  • Blocking Writes:
    File writes happen on the calling thread, so be mindful of performance in critical sections.

License

MIT OR Apache-2.0

Copyright (c) 2023 Ever Accountable

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
  
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
  
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.