amethystate 0.9.1

Type-safe reactive persistence for Rust GUI apps
Documentation
pub mod document;
pub mod error;
#[cfg(feature = "json")]
pub mod json;
pub mod migration;
#[cfg(feature = "ron")]
pub mod ron;
pub mod store;
#[cfg(feature = "toml")]
pub mod toml;

pub use document::TextDocument;
pub use error::TextStoreError;
pub use store::TextStore;

#[cfg(feature = "json")]
pub use json::JsonStore;

#[cfg(feature = "toml")]
pub use toml::TomlStore;

#[cfg(feature = "ron")]
pub use ron::RonStore;

#[macro_export]
macro_rules! define_store_test_suite {
    ($store_type:ident, $ext:expr, $watch_set_false:expr, $watch_set_true:expr, $watch_delete_empty:expr) => {
        #[cfg(test)]
        mod store_tests {
            use super::*;
            use parking_lot::Mutex;
            use std::path::PathBuf;
            use std::sync::Arc;
            use std::time::{Duration, SystemTime, UNIX_EPOCH};
            use $crate::store::config::StoreConfig;
            use $crate::store::{Store, StoreEvent, StoreOp, SubscriptionKind};

            fn unique_test_path(suffix: &str) -> PathBuf {
                let nanos = SystemTime::now()
                    .duration_since(UNIX_EPOCH)
                    .expect("time is after epoch")
                    .as_nanos();
                std::env::temp_dir().join(format!(
                    "amethystate-{}-{suffix}-{nanos}.{}",
                    stringify!($store_type),
                    $ext
                ))
            }

            fn make_store(suffix: &str) -> $store_type {
                $store_type::open(
                    StoreConfig::new(unique_test_path(suffix)),
                    Default::default(),
                )
                .unwrap()
                .0
            }

            fn open_store_at(path: PathBuf) -> $store_type {
                $store_type::open(StoreConfig::new(path), Default::default())
                    .unwrap()
                    .0
            }

            #[test]
            fn set_get_delete_roundtrip() {
                let store = make_store("roundtrip");

                store
                    .set("ui.theme.dark", &true)
                    .expect("set should succeed");
                assert_eq!(store.get::<bool>("ui.theme.dark").unwrap(), Some(true));

                store
                    .delete("ui.theme.dark")
                    .expect("delete should succeed");
                assert_eq!(store.get::<bool>("ui.theme.dark").unwrap(), None);
            }

            #[test]
            fn subscriptions_any_exact_prefix_fire() {
                let store = make_store("subscriptions");

                let any_hits: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(vec![]));
                let exact_hits: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(vec![]));
                let prefix_hits: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(vec![]));

                let cap = any_hits.clone();
                store.subscribe(
                    SubscriptionKind::Any,
                    Arc::new(move |evt| {
                        cap.lock().push(evt.path.to_string());
                    }),
                );

                let cap = exact_hits.clone();
                store.subscribe(
                    SubscriptionKind::ExactPath(Arc::from("ui.theme.dark")),
                    Arc::new(move |evt| {
                        cap.lock().push(evt.path.to_string());
                    }),
                );

                let cap = prefix_hits.clone();
                store.subscribe(
                    SubscriptionKind::Prefix(Arc::from("ui.theme")),
                    Arc::new(move |evt| {
                        cap.lock().push(evt.path.to_string());
                    }),
                );

                store
                    .set("ui.theme.dark", &true)
                    .expect("set should succeed");
                store
                    .set("ui.layout.sidebar_width", &260u64)
                    .expect("set should succeed");

                assert_eq!(any_hits.lock().len(), 2);
                assert_eq!(exact_hits.lock().as_slice(), ["ui.theme.dark"]);
                assert_eq!(prefix_hits.lock().as_slice(), ["ui.theme.dark"]);
            }

            #[test]
            fn unsubscribe_stops_callbacks() {
                let store = make_store("unsubscribe");

                let hit_count = Arc::new(Mutex::new(0usize));
                let cap = hit_count.clone();
                let id = store.subscribe(
                    SubscriptionKind::Any,
                    Arc::new(move |_| {
                        *cap.lock() += 1;
                    }),
                );

                store
                    .set("ui.theme.dark", &true)
                    .expect("set should succeed");
                store.unsubscribe(id);
                store
                    .set("ui.theme.dark", &false)
                    .expect("set should succeed");

                assert_eq!(*hit_count.lock(), 1);
            }

            #[test]
            fn file_watch_emits_set_for_external_change() {
                let path = unique_test_path("watch-set");
                std::fs::write(&path, $watch_set_false).expect("seed file should be written");

                let store = open_store_at(path.clone());
                let (tx, rx) = std::sync::mpsc::channel::<StoreEvent>();

                store.subscribe(
                    SubscriptionKind::ExactPath(Arc::from("ui.theme.dark")),
                    Arc::new(move |evt| {
                        let _ = tx.send(evt.clone());
                    }),
                );

                std::fs::write(&path, $watch_set_true).expect("updated file should be written");

                let event = rx
                    .recv_timeout(Duration::from_secs(3))
                    .expect("watcher should emit set event");

                assert_eq!(&*event.path, "ui.theme.dark");
                assert_eq!(event.op, StoreOp::Set);
                let old_val: bool = store.decode(&event.old.as_ref().unwrap()).unwrap();
                let new_val: bool = store.decode(&event.new.as_ref().unwrap()).unwrap();
                assert_eq!(old_val, false);
                assert_eq!(new_val, true);
            }

            #[test]
            fn file_watch_emits_delete_for_external_removal() {
                let path = unique_test_path("watch-delete");
                std::fs::write(&path, $watch_set_true).expect("seed file should be written");

                let store = open_store_at(path.clone());
                let (tx, rx) = std::sync::mpsc::channel::<StoreEvent>();

                store.subscribe(
                    SubscriptionKind::ExactPath(Arc::from("ui.theme.dark")),
                    Arc::new(move |evt| {
                        let _ = tx.send(evt.clone());
                    }),
                );

                std::fs::write(&path, $watch_delete_empty).expect("updated file should be written");

                let event = rx
                    .recv_timeout(Duration::from_secs(3))
                    .expect("watcher should emit delete event");

                assert_eq!(&*event.path, "ui.theme.dark");
                assert_eq!(event.op, StoreOp::Delete);
                let old_val: bool = store.decode(&event.old.as_ref().unwrap()).unwrap();
                assert_eq!(old_val, true);
                assert_eq!(event.new, None);
            }

            #[test]
            fn save_now_and_persist() {
                let path = unique_test_path("save_now");
                let store = open_store_at(path.clone());

                store.set("app.version", &"1.0.0".to_string()).unwrap();
                store.set("app.debug", &true).unwrap();

                if path.exists() {
                    std::fs::remove_file(&path).unwrap();
                }

                store.save_now().unwrap();

                assert!(path.exists());
                let content = std::fs::read_to_string(&path).unwrap();
                assert!(content.contains("1.0.0"));
            }

            #[test]
            fn test_is_initialized_false_on_fresh_store() {
                let store = make_store("init_fresh");
                assert!(!store.is_initialized("settings").unwrap());
            }

            #[test]
            fn test_mark_and_is_initialized() {
                let store = make_store("init_mark");
                assert!(!store.is_initialized("settings").unwrap());
                store.mark_initialized("settings").unwrap();
                assert!(store.is_initialized("settings").unwrap());
            }

            #[test]
            fn test_initialized_namespaces_are_independent() {
                let store = make_store("init_namespaces");
                store.mark_initialized("settings").unwrap();
                assert!(store.is_initialized("settings").unwrap());
                assert!(!store.is_initialized("other").unwrap());
            }

            #[test]
            fn test_init_key_does_not_appear_in_scan_prefix() {
                let store = make_store("init_scan");
                store.mark_initialized("settings").unwrap();
                store
                    .set("settings.host", &"localhost".to_string())
                    .unwrap();

                let entries = store.scan_prefix("settings").unwrap();
                assert!(
                    entries.iter().all(|(k, _)| !k.contains("__init")),
                    "init key should not appear in scan_prefix results"
                );
            }
        }
    };
}