use std::rc::Rc;
use std::time::Duration;
use crate::file::SettingsFileError;
use crate::path::AppPaths;
use crate::reload::Reloadable;
use crate::store::{DEFAULT_DEBOUNCE, SettingsStore, SettingsStoreError};
use crate::watch::SettingsRegistry;
use crate::window_state::WindowStateService;
#[derive(Debug, thiserror::Error)]
pub enum SettingsBundleError {
#[error("settings bundle: {0}")]
Store(#[from] SettingsStoreError),
#[error("settings bundle: {0}")]
File(#[from] SettingsFileError),
}
#[derive(Debug, Clone)]
pub struct SettingsBundle {
store_name: String,
window_state_enabled: bool,
debounce: Duration,
}
impl SettingsBundle {
pub fn new() -> Self {
Self {
store_name: "general".into(),
window_state_enabled: false,
debounce: DEFAULT_DEBOUNCE,
}
}
pub fn with_store_name(mut self, name: impl Into<String>) -> Self {
self.store_name = name.into();
self
}
pub fn with_window_state(mut self, enabled: bool) -> Self {
self.window_state_enabled = enabled;
self
}
pub fn with_debounce(mut self, delay: Duration) -> Self {
self.debounce = delay;
self
}
pub fn store_name(&self) -> &str {
&self.store_name
}
pub fn debounce(&self) -> Duration {
self.debounce
}
pub fn open(self, paths: &AppPaths) -> Result<OpenedSettings, SettingsBundleError> {
let store =
SettingsStore::open_with_delay(paths.config_file(&self.store_name), self.debounce)?;
let window_state = if self.window_state_enabled {
Some(WindowStateService::open_with_delay(paths, self.debounce)?)
} else {
None
};
let registry = SettingsRegistry::new();
let mut reload_handles: Vec<Rc<dyn Reloadable>> = Vec::new();
reload_handles.push(registry.register(Rc::new(store.clone())));
if let Some(window_state) = &window_state {
reload_handles.push(registry.register(Rc::new(window_state.clone())));
}
Ok(OpenedSettings {
store,
window_state,
registry,
reload_handles,
})
}
}
impl Default for SettingsBundle {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone)]
pub struct OpenedSettings {
pub store: SettingsStore,
pub window_state: Option<WindowStateService>,
pub registry: SettingsRegistry,
reload_handles: Vec<Rc<dyn Reloadable>>,
}
impl std::fmt::Debug for OpenedSettings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OpenedSettings")
.field("store", &self.store)
.field("window_state", &self.window_state)
.field("registry", &self.registry)
.field("reload_handles", &self.reload_handles.len())
.finish()
}
}
impl OpenedSettings {
pub fn flush_all(&self) -> Result<(), SettingsBundleError> {
self.store.flush_now()?;
if let Some(w) = &self.window_state {
w.flush_now()?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn empty_bundle_opens_only_store() {
let dir = tempdir().unwrap();
let paths = AppPaths::for_testing(dir.path());
let opened = SettingsBundle::new()
.with_debounce(Duration::ZERO)
.open(&paths)
.unwrap();
assert!(opened.window_state.is_none());
}
#[test]
fn full_bundle_opens_window_state() {
let dir = tempdir().unwrap();
let paths = AppPaths::for_testing(dir.path());
let opened = SettingsBundle::new()
.with_window_state(true)
.with_debounce(Duration::ZERO)
.open(&paths)
.unwrap();
assert!(opened.window_state.is_some());
}
#[test]
fn store_name_overrides_path() {
let dir = tempdir().unwrap();
let paths = AppPaths::for_testing(dir.path());
let opened = SettingsBundle::new()
.with_store_name("editor")
.with_debounce(Duration::ZERO)
.open(&paths)
.unwrap();
assert_eq!(opened.store.path(), paths.config_file("editor"));
}
const NAME: crate::store::SettingsKey<String> =
crate::store::SettingsKey::new("user.name", String::new);
#[test]
fn opened_settings_registry_dispatches_a_peers_store_write() {
let dir = tempdir().unwrap();
let paths = AppPaths::for_testing(dir.path());
let mine = SettingsBundle::new()
.with_debounce(Duration::ZERO)
.open(&paths)
.unwrap();
let name_signal = mine.store.signal_for(&NAME);
assert_eq!(name_signal.get(), "");
let peer = SettingsBundle::new()
.with_debounce(Duration::ZERO)
.open(&paths)
.unwrap();
peer.store.signal_for(&NAME).set("peer-name".to_string());
peer.flush_all().unwrap();
let changed = mine.store.path().to_path_buf();
assert!(mine.registry.dispatch(&changed).unwrap());
assert_eq!(name_signal.get(), "peer-name");
}
#[test]
fn opened_settings_registry_dispatches_a_peers_window_state_write() {
let dir = tempdir().unwrap();
let paths = AppPaths::for_testing(dir.path());
let mine = SettingsBundle::new()
.with_window_state(true)
.with_debounce(Duration::ZERO)
.open(&paths)
.unwrap();
let window_state = mine.window_state.clone().unwrap();
assert!(window_state.state_for("main").is_none());
let peer = SettingsBundle::new()
.with_window_state(true)
.with_debounce(Duration::ZERO)
.open(&paths)
.unwrap();
peer.window_state
.as_ref()
.unwrap()
.record(crate::window_state::PerWindowState {
label: "main".into(),
x: 10,
y: 20,
width: 800,
height: 600,
placement: Default::default(),
})
.unwrap();
peer.window_state.as_ref().unwrap().flush_now().unwrap();
let changed = window_state.path().to_path_buf();
assert!(mine.registry.dispatch(&changed).unwrap());
let restored = window_state.state_for("main").unwrap();
assert_eq!(restored.width, 800);
assert_eq!(restored.height, 600);
}
#[test]
fn dropping_opened_settings_deregisters_its_services() {
let dir = tempdir().unwrap();
let paths = AppPaths::for_testing(dir.path());
let opened = SettingsBundle::new()
.with_window_state(true)
.with_debounce(Duration::ZERO)
.open(&paths)
.unwrap();
let registry = opened.registry.clone();
assert_eq!(registry.live_count(), 2, "store + window_state");
drop(opened);
assert_eq!(
registry.live_count(),
0,
"dropping every OpenedSettings clone must drop its reload_handles too"
);
}
}