use super::{model::SettingsFile, store};
use std::{
path::PathBuf,
sync::{Arc, Mutex, mpsc},
thread,
time::{Duration, Instant},
};
use tracing::warn;
const DEBOUNCE: Duration = Duration::from_millis(600);
const POLL_INTERVAL: Duration = Duration::from_millis(100);
enum Msg {
Changed(SettingsFile),
FlushNow(SettingsFile),
Shutdown,
}
#[derive(Clone)]
pub struct SettingsPersister {
sender: mpsc::Sender<Msg>,
current: Arc<Mutex<SettingsFile>>,
}
impl SettingsPersister {
#[must_use]
pub fn spawn(path: PathBuf, initial: SettingsFile) -> Self {
let (sender, receiver) = mpsc::channel::<Msg>();
let current = Arc::new(Mutex::new(initial));
thread::spawn(move || worker_loop(&path, &receiver));
Self { sender, current }
}
pub fn update(&self, f: impl FnOnce(&mut SettingsFile)) {
let mut guard = self
.current
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
f(&mut guard);
let snapshot = guard.clone();
drop(guard);
let _ = self.sender.send(Msg::Changed(snapshot));
}
#[must_use]
pub fn snapshot(&self) -> SettingsFile {
self.current
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub fn flush(&self) {
let snapshot = self.snapshot();
let _ = self.sender.send(Msg::FlushNow(snapshot));
}
}
impl Drop for SettingsPersister {
fn drop(&mut self) {
let _ = self.sender.send(Msg::Shutdown);
}
}
fn worker_loop(path: &std::path::Path, receiver: &mpsc::Receiver<Msg>) {
let mut pending: Option<SettingsFile> = None;
let mut last_change = Instant::now();
loop {
match receiver.recv_timeout(POLL_INTERVAL) {
Ok(Msg::Changed(settings)) => {
pending = Some(settings);
last_change = Instant::now();
}
Ok(Msg::FlushNow(settings)) => {
write_settings(path, &settings);
pending = None;
}
Ok(Msg::Shutdown) => {
if let Some(settings) = pending.take() {
write_settings(path, &settings);
}
break;
}
Err(mpsc::RecvTimeoutError::Timeout) => {
if let Some(settings) = &pending
&& last_change.elapsed() >= DEBOUNCE
{
write_settings(path, settings);
pending = None;
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
}
fn write_settings(path: &std::path::Path, settings: &SettingsFile) {
if let Err(e) = store::save(path, settings) {
warn!("Failed to save settings to {}: {e}", path.display());
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
fn temp_settings_path(tag: &str) -> PathBuf {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"indicatrix-cut-persist-test-{tag}-{}-{n}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
dir.join("settings.toml")
}
#[test]
fn flush_writes_immediately_without_waiting_for_the_debounce() {
let path = temp_settings_path("flush");
let persister = SettingsPersister::spawn(path.clone(), SettingsFile::default());
persister.update(|s| s.settings.exposure = 2.5);
persister.flush();
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Ok(contents) = std::fs::read_to_string(&path)
&& contents.contains("exposure = 2.5")
{
break;
}
assert!(Instant::now() < deadline, "flush did not persist in time");
thread::sleep(Duration::from_millis(20));
}
}
#[test]
fn snapshot_reflects_update_immediately_even_before_the_write_lands() {
let path = temp_settings_path("snapshot");
let persister = SettingsPersister::spawn(path, SettingsFile::default());
persister.update(|s| s.settings.max_bounces = 42);
assert_eq!(persister.snapshot().settings.max_bounces, 42);
}
}