pub mod storage;
pub use once_cell;
pub use paste; pub use toml; pub use web_time;
#[derive(Debug)]
pub enum LoadError {
InstanceAlreadyLoaded,
DeserializationError(String, toml::de::Error),
StorageError(std::io::Error),
}
impl std::fmt::Display for LoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InstanceAlreadyLoaded => {
write!(f, "another preferences instance is already loaded")
}
Self::DeserializationError(location, e) => {
write!(f, "deserialization error: {e} at {location}")
}
Self::StorageError(e) => write!(f, "storage error: {e}"),
}
}
}
impl std::error::Error for LoadError {}
#[macro_export]
macro_rules! easy_prefs {
(
$(#[$outer:meta])*
$vis:vis struct $name:ident {
$(
$(#[$inner:meta])*
$field_vis:vis $field:ident: $type:ty = $default:expr => $saved_name:expr,
)*
},
$preferences_filename:expr
) => {
$crate::paste::paste!{
static [<$name:upper _INSTANCE_EXISTS>]: $crate::once_cell::sync::Lazy<std::sync::atomic::AtomicBool> =
$crate::once_cell::sync::Lazy::new(|| std::sync::atomic::AtomicBool::new(false));
#[derive(Debug)]
struct [<$name InstanceGuard>];
impl Drop for [<$name InstanceGuard>] {
fn drop(&mut self) {
[<$name:upper _INSTANCE_EXISTS>].store(false, std::sync::atomic::Ordering::Release);
}
}
$(#[$outer])*
#[derive(serde::Serialize, serde::Deserialize, Debug)]
#[serde(default)] $vis struct $name {
$(
$(#[$inner])*
#[serde(rename = $saved_name)]
$field_vis [<_ $field>]: $type,
)*
#[serde(skip_serializing, skip_deserializing)]
storage: Option<Box<dyn $crate::storage::Storage>>,
#[serde(skip_serializing, skip_deserializing)]
storage_key: Option<String>,
#[serde(skip_serializing, skip_deserializing)]
#[cfg(not(target_arch = "wasm32"))]
temp_file: Option<tempfile::NamedTempFile>,
#[serde(skip_serializing, skip_deserializing)]
_instance_guard: Option<[<$name InstanceGuard>]>,
}
impl Default for $name {
fn default() -> Self {
Self {
$( [<_ $field>]: $default, )*
storage: None,
storage_key: None,
#[cfg(not(target_arch = "wasm32"))]
temp_file: None,
_instance_guard: None,
}
}
}
impl $name {
pub const PREFERENCES_FILENAME: &'static str = concat!($preferences_filename, ".toml");
pub fn load(directory: &str) -> Self {
match Self::load_with_error(directory) {
Ok(prefs) => prefs,
Err(e) => {
if matches!(e, $crate::LoadError::InstanceAlreadyLoaded) {
panic!("Failed to load preferences: {}", e);
}
#[cfg(any(debug_assertions, test))]
{
panic!("Failed to load preferences: {}", e);
}
#[cfg(not(any(debug_assertions, test)))]
{
eprintln!("Failed to load preferences from {}: {}, using defaults", directory, e);
let was_free = [<$name:upper _INSTANCE_EXISTS>].compare_exchange(
false, true, std::sync::atomic::Ordering::Acquire, std::sync::atomic::Ordering::Relaxed
);
if was_free.is_err() {
panic!("Failed to load preferences and instance is still locked: {}", e);
}
let guard = [<$name InstanceGuard>];
let storage = $crate::storage::create_storage(directory);
let storage_key = Self::PREFERENCES_FILENAME;
let mut cfg = Self::default();
cfg.storage = Some(storage);
cfg.storage_key = Some(storage_key.to_string());
cfg._instance_guard = Some(guard);
cfg
}
}
}
}
pub fn load_with_error(directory: &str) -> Result<Self, $crate::LoadError> {
{
use std::collections::HashSet;
let keys = [ $( ($saved_name, stringify!($field) ), )* ];
let mut seen = HashSet::new();
for (key, field_name) in keys.iter() {
if !seen.insert(*key) {
panic!("Duplicate saved_name '{}' found for field '{}'", key, field_name);
}
}
}
let was_free = [<$name:upper _INSTANCE_EXISTS>].compare_exchange(
false, true, std::sync::atomic::Ordering::Acquire, std::sync::atomic::Ordering::Relaxed
);
if was_free.is_err() {
return Err($crate::LoadError::InstanceAlreadyLoaded);
}
let guard = [<$name InstanceGuard>];
let storage = $crate::storage::create_storage(directory);
let storage_key = Self::PREFERENCES_FILENAME;
let mut cfg = match storage.read(storage_key).map_err($crate::LoadError::StorageError)? {
Some(contents) => {
$crate::toml::from_str::<Self>(&contents)
.map_err(|e| $crate::LoadError::DeserializationError(
storage.get_path(storage_key), e
))?
}
None => Self::default(),
};
cfg.storage = Some(storage);
cfg.storage_key = Some(storage_key.to_string());
cfg._instance_guard = Some(guard);
Ok(cfg)
}
#[deprecated(
since = "3.0.0",
note = "Use `load()` instead - it handles errors gracefully without compromising safety"
)]
pub fn load_default(_directory_or_app_id: &str) -> Self {
panic!(
"load_default() has been removed in version 3.0.0 because it bypassed safety constraints. \
Use load() instead, which handles errors gracefully while maintaining the single-instance guarantee. \
See the documentation for more details."
);
}
#[cfg(not(target_arch = "wasm32"))]
pub fn load_testing() -> Self {
let tmp_file = tempfile::NamedTempFile::with_prefix(Self::PREFERENCES_FILENAME)
.expect("Failed to create temporary file for testing preferences");
let tmp_dir = tmp_file.path().parent().unwrap().to_str().unwrap();
let storage = $crate::storage::create_storage(tmp_dir);
let storage_key = tmp_file.path().file_name().unwrap().to_str().unwrap();
let mut cfg = Self::default();
let serialized = $crate::toml::to_string(&cfg).unwrap();
storage.write(storage_key, &serialized)
.expect("Failed to write preferences data to temporary file");
cfg.storage = Some(storage);
cfg.storage_key = Some(storage_key.to_string());
cfg.temp_file = Some(tmp_file);
cfg
}
#[cfg(target_arch = "wasm32")]
pub fn load_testing() -> Self {
let test_id = format!("test_{}", $crate::web_time::SystemTime::now()
.duration_since($crate::web_time::UNIX_EPOCH)
.unwrap()
.as_millis());
let storage = $crate::storage::create_storage(&test_id);
let storage_key = Self::PREFERENCES_FILENAME;
let mut cfg = Self::default();
cfg.storage = Some(storage);
cfg.storage_key = Some(storage_key.to_string());
cfg
}
pub fn to_string(&self) -> String {
$crate::toml::to_string(self).expect("Serialization failed")
}
pub fn save(&self) -> Result<(), std::io::Error> {
let storage = self.storage.as_ref().ok_or_else(|| std::io::Error::new(
std::io::ErrorKind::Other,
"storage not initialized"
))?;
let storage_key = self.storage_key.as_ref().ok_or_else(|| std::io::Error::new(
std::io::ErrorKind::Other,
"storage key not set"
))?;
let serialized = $crate::toml::to_string(self).map_err(|e| std::io::Error::new(
std::io::ErrorKind::Other,
format!("serialization failed: {}", e)
))?;
storage.write(storage_key, &serialized)?;
Ok(())
}
pub fn get_preferences_file_path(&self) -> String {
match (&self.storage, &self.storage_key) {
(Some(storage), Some(key)) => storage.get_path(key),
_ => panic!("storage not initialized"),
}
}
$(
pub fn [<get_ $field>](&self) -> &$type {
&self.[<_ $field>]
}
pub fn [<save_ $field>](&mut self, value: $type) -> Result<(), std::io::Error> {
if self.[<_ $field>] != value {
self.[<_ $field>] = value;
self.save()
} else {
Ok(())
}
}
)*
pub fn edit(&mut self) -> [<$name EditGuard>] {
[<$name EditGuard>] {
preferences: self,
modified: false,
created: $crate::web_time::Instant::now()
}
}
}
$vis struct [<$name EditGuard>]<'a> {
preferences: &'a mut $name,
modified: bool,
created: $crate::web_time::Instant,
}
impl<'a> [<$name EditGuard>]<'a> {
$(
pub fn [<set_ $field>](&mut self, value: $type) {
if self.preferences.[<_ $field>] != value {
self.preferences.[<_ $field>] = value;
self.modified = true;
}
}
pub fn [<get_ $field>](&self) -> &$type {
&self.preferences.[<_ $field>]
}
)*
}
impl<'a> Drop for [<$name EditGuard>]<'a> {
fn drop(&mut self) {
if cfg!(debug_assertions) && !std::thread::panicking() {
let duration = self.created.elapsed();
if duration.as_secs() >= 1 {
eprintln!("Warning: Edit guard held for {:?} - consider reducing the scope", duration);
}
}
if self.modified {
if let Err(e) = self.preferences.save() {
eprintln!("Failed to save: {}", e);
}
}
}
}
}
}
}
#[allow(dead_code)]
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Barrier, Mutex};
use std::thread;
use web_time::Duration;
#[cfg(debug_assertions)]
easy_prefs! {
struct TestEasyPreferences {
pub bool1_default_true: bool = true => "bool1_default_true",
pub bool2_default_true: bool = true => "bool2_default_true",
pub bool3_initial_default_false: bool = false => "bool3_initial_default_false",
pub string1: String = String::new() => "string1",
pub int1: i32 = 42 => "int1",
}, "test-easy-prefs"
}
#[cfg(debug_assertions)]
easy_prefs! {
pub struct TestEasyPreferencesUpdated {
pub bool2_default_true_renamed: bool = true => "bool2_default_true",
pub bool3_initial_default_false: bool = true => "bool3_initial_default_false",
pub bool4_default_true: bool = true => "bool4_default_true",
pub string1: String = "ea".to_string() => "string1",
pub string2: String = "new default value".to_string() => "string2",
}, "test-easy-prefs"
}
#[test]
fn test_load_save_preferences_with_macro() {
let mut prefs = TestEasyPreferences::load_testing();
assert_eq!(prefs.get_bool1_default_true(), &true);
assert_eq!(prefs.get_int1(), &42);
prefs
.save_bool1_default_true(false)
.expect("Failed to save bool1");
prefs
.save_string1("hi".to_string())
.expect("Failed to save string1");
let file_path = prefs.get_preferences_file_path();
assert!(file_path.contains("test-easy-prefs"));
#[cfg(not(target_arch = "wasm32"))]
{
let contents = std::fs::read_to_string(&file_path).expect("Failed to read file");
assert!(contents.contains("bool1_default_true = false"));
assert!(contents.contains("string1 = \"hi\""));
}
}
#[test]
fn test_edit_guard() {
let mut prefs = TestEasyPreferences::load_testing();
{
let mut guard = prefs.edit();
guard.set_bool1_default_true(false);
guard.set_int1(43);
}
assert_eq!(prefs.get_bool1_default_true(), &false);
assert_eq!(prefs.get_int1(), &43);
#[cfg(not(target_arch = "wasm32"))]
{
let contents = std::fs::read_to_string(prefs.get_preferences_file_path())
.expect("Failed to read file");
assert!(contents.contains("bool1_default_true = false"));
assert!(contents.contains("int1 = 43"));
}
}
#[test]
fn test_with_arc_mutex() {
let prefs = Arc::new(Mutex::new(TestEasyPreferences::load_testing()));
{
let prefs = prefs.lock().unwrap();
assert_eq!(prefs.get_int1(), &42);
}
{
let mut prefs = prefs.lock().unwrap();
prefs.save_int1(100).expect("Failed to save int1");
}
{
let prefs = prefs.lock().unwrap();
assert_eq!(prefs.get_int1(), &100);
}
}
#[test]
fn test_real_preferences_and_single_instance() {
{
let path = {
let prefs = TestEasyPreferences::load("/tmp/tests/");
prefs.get_preferences_file_path()
};
let _ = std::fs::remove_file(&path);
{
let mut prefs = TestEasyPreferences::load("/tmp/tests/");
prefs
.save_bool1_default_true(false)
.expect("Failed to save bool1");
prefs.edit().set_string1("test1".to_string());
}
{
let prefs = TestEasyPreferences::load("/tmp/tests/");
assert_eq!(prefs.get_bool1_default_true(), &false);
assert_eq!(prefs.get_string1(), "test1");
}
{
let prefs = TestEasyPreferencesUpdated::load("/tmp/tests/");
assert_eq!(prefs.get_bool2_default_true_renamed(), &true); assert_eq!(prefs.get_string1(), "test1");
assert_eq!(prefs.get_string2(), "new default value");
}
}
let barrier = Arc::new(Barrier::new(2));
let barrier_clone = barrier.clone();
let test_dir = "/tmp/test_instance_conflict/";
let handle = thread::spawn(move || {
let prefs = TestEasyPreferences::load_with_error(test_dir).expect("Failed to load");
barrier_clone.wait(); thread::sleep(Duration::from_millis(100));
drop(prefs); true
});
barrier.wait(); let result = TestEasyPreferences::load_with_error(test_dir);
assert!(matches!(result, Err(LoadError::InstanceAlreadyLoaded)));
handle.join().unwrap();
let _prefs = TestEasyPreferences::load(test_dir);
let _test1 = TestEasyPreferences::load_testing();
let _test2 = TestEasyPreferences::load_testing();
}
#[test]
#[should_panic(expected = "Failed to load preferences")]
#[cfg(debug_assertions)]
fn test_load_panics_on_error_in_debug() {
let test_dir = "/tmp/tests_panic/";
let _prefs1 = TestEasyPreferences::load(test_dir);
let _prefs2 = TestEasyPreferences::load(test_dir);
}
}