use std::any::{Any, TypeId};
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::rc::{Rc, Weak};
use std::time::{Duration, SystemTime};
use serde::Serialize;
use serde::de::DeserializeOwned;
use teksilo_core::ObserverHandle;
use teksilo_core::signal::Signal;
use crate::file::{SettingsFileError, disk_stamp};
use crate::flush::{DebouncedWriter, FlushError, Patch};
use crate::reload::Reloadable;
pub const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(500);
#[derive(Debug, thiserror::Error)]
pub enum SettingsStoreError {
#[error("settings store I/O: {0}")]
Io(#[from] io::Error),
#[error("settings store parse: {0}")]
Parse(#[source] toml::de::Error),
#[error("settings store flush: {0}")]
Flush(#[source] FlushError),
}
impl From<SettingsStoreError> for SettingsFileError {
fn from(e: SettingsStoreError) -> Self {
match e {
SettingsStoreError::Io(e) => SettingsFileError::Io(e),
SettingsStoreError::Parse(e) => SettingsFileError::Parse(e),
SettingsStoreError::Flush(e) => SettingsFileError::Flush(e),
}
}
}
pub struct SettingsKey<T: 'static> {
pub key: &'static str,
pub default: fn() -> T,
}
impl<T: 'static> SettingsKey<T> {
pub const fn new(key: &'static str, default: fn() -> T) -> Self {
Self { key, default }
}
}
pub const TEXT_SCALE_KEY: SettingsKey<f32> =
SettingsKey::new("accessibility.text_scale", || 1.0_f32);
impl<T: 'static> std::fmt::Debug for SettingsKey<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SettingsKey")
.field("key", &self.key)
.field("type", &std::any::type_name::<T>())
.finish()
}
}
struct SignalCell {
type_id: TypeId,
type_name: &'static str,
signal: Box<dyn Any>,
apply_external: Box<dyn Fn(&toml::Value)>,
_handle: ObserverHandle,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DirtyKind {
SeedIfAbsent,
Set,
}
struct StoreInner {
raw: toml::Value,
cells: HashMap<String, SignalCell>,
writer: DebouncedWriter,
dirty: Vec<(String, toml::Value, DirtyKind)>,
applying_external: Cell<bool>,
last_known_stamp: Cell<(Option<SystemTime>, Option<u64>)>,
}
impl StoreInner {
fn schedule_dirty_flush(&mut self) {
if self.dirty.is_empty() {
return;
}
let batch: Vec<(String, toml::Value, DirtyKind)> = std::mem::take(&mut self.dirty);
let patch: Patch = Box::new(move |current: Option<String>| {
let mut doc: toml::Value = match current {
Some(s) => toml::from_str(&s).map_err(|e| FlushError::Merge(e.to_string()))?,
None => empty_table(),
};
if !doc.is_table() {
doc = empty_table();
}
for (k, v, kind) in &batch {
match kind {
DirtyKind::Set => write_nested(&mut doc, k, v.clone()),
DirtyKind::SeedIfAbsent => {
if get_nested(&doc, k).is_none() {
write_nested(&mut doc, k, v.clone());
}
}
}
}
toml::to_string_pretty(&doc).map_err(|e| FlushError::Merge(e.to_string()))
});
self.writer.schedule(patch);
}
}
pub struct SettingsStore {
inner: Rc<RefCell<StoreInner>>,
pending: Rc<RefCell<Vec<(String, toml::Value)>>>,
path: PathBuf,
}
impl Clone for SettingsStore {
fn clone(&self) -> Self {
Self {
inner: Rc::clone(&self.inner),
pending: Rc::clone(&self.pending),
path: self.path.clone(),
}
}
}
impl SettingsStore {
pub fn open(path: PathBuf) -> Result<Self, SettingsStoreError> {
Self::open_with_delay(path, DEFAULT_DEBOUNCE)
}
pub fn open_with_delay(path: PathBuf, delay: Duration) -> Result<Self, SettingsStoreError> {
let raw = match fs::read_to_string(&path) {
Ok(s) => toml::from_str::<toml::Value>(&s).map_err(SettingsStoreError::Parse)?,
Err(e) if e.kind() == io::ErrorKind::NotFound => empty_table(),
Err(e) => return Err(SettingsStoreError::Io(e)),
};
let raw = match raw {
v @ toml::Value::Table(_) => v,
_ => empty_table(),
};
let stamp = disk_stamp(&path);
let writer = DebouncedWriter::new(path.clone(), delay);
let inner = Rc::new(RefCell::new(StoreInner {
raw,
cells: HashMap::new(),
writer,
dirty: Vec::new(),
applying_external: Cell::new(false),
last_known_stamp: Cell::new(stamp),
}));
Ok(Self {
inner,
pending: Rc::new(RefCell::new(Vec::new())),
path,
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn flush_now(&self) -> Result<(), SettingsStoreError> {
if let Ok(mut inner) = self.inner.try_borrow_mut() {
let deferred: Vec<(String, toml::Value)> =
self.pending.borrow_mut().drain(..).collect();
if !deferred.is_empty() {
for (k, v) in deferred {
write_nested(&mut inner.raw, &k, v.clone());
inner.dirty.push((k, v, DirtyKind::Set));
}
inner.schedule_dirty_flush();
}
}
self.inner
.borrow()
.writer
.flush_now()
.map_err(SettingsStoreError::Flush)?;
let _ = self.resync_with_disk()?;
Ok(())
}
pub fn has(&self, key: &str) -> bool {
self.inner.borrow().cells.contains_key(key)
}
pub fn registered_keys(&self) -> Vec<String> {
self.inner.borrow().cells.keys().cloned().collect()
}
pub fn signal<T>(&self, key: &str, default: T) -> Signal<T>
where
T: Clone + Serialize + DeserializeOwned + 'static,
{
if let Some(existing) = self.try_existing::<T>(key) {
return existing;
}
let mut inner = self.inner.borrow_mut();
if let Some(cell) = inner.cells.get(key) {
return downcast_or_panic::<T>(key, cell);
}
if let Err(err) = check_path_shape(&inner.raw, key) {
panic!("{}", err.message_for(key, std::any::type_name::<T>()));
}
let initial = match get_nested(&inner.raw, key) {
Some(v) => match T::deserialize(v.clone()) {
Ok(v) => v,
Err(_) => default,
},
None => default,
};
let initial_value =
serialize_to_value(&initial).expect("initial T value must serialize as TOML");
if matches!(&initial_value, toml::Value::Table(_)) {
panic!(
"SettingsStore: cannot register key \"{key}\" as {ty} — \
struct values serialize as TOML tables, which collide with \
the store's nested-key model. Use SettingsFile<{ty}> \
instead.",
ty = std::any::type_name::<T>(),
);
}
let sig: Signal<T> = Signal::new(initial.clone());
write_nested(&mut inner.raw, key, initial_value.clone());
let key_owned = key.to_string();
let weak: Weak<RefCell<StoreInner>> = Rc::downgrade(&self.inner);
let weak_pending: Weak<RefCell<Vec<(String, toml::Value)>>> = Rc::downgrade(&self.pending);
let handle = sig.observe(move |new_val: &T| {
let Some(inner_rc) = weak.upgrade() else {
return;
};
let Some(pending_rc) = weak_pending.upgrade() else {
return;
};
if inner_rc
.try_borrow()
.map(|r| r.applying_external.get())
.unwrap_or(false)
{
return;
}
let value = match serialize_to_value(new_val) {
Ok(v) => v,
Err(_) => return,
};
match inner_rc.try_borrow_mut() {
Ok(mut inner) => {
let deferred: Vec<(String, toml::Value)> =
pending_rc.borrow_mut().drain(..).collect();
for (k, v) in deferred {
write_nested(&mut inner.raw, &k, v.clone());
inner.dirty.push((k, v, DirtyKind::Set));
}
write_nested(&mut inner.raw, &key_owned, value.clone());
inner.dirty.push((key_owned.clone(), value, DirtyKind::Set));
inner.schedule_dirty_flush();
}
Err(_) => {
pending_rc.borrow_mut().push((key_owned.clone(), value));
}
}
});
let apply_external: Box<dyn Fn(&toml::Value)> = {
let sig_for_apply = sig.clone();
Box::new(move |fresh: &toml::Value| {
if let Ok(value) = T::deserialize(fresh.clone()) {
sig_for_apply.set(value);
}
})
};
let cell = SignalCell {
type_id: TypeId::of::<T>(),
type_name: std::any::type_name::<T>(),
signal: Box::new(sig.clone()),
apply_external,
_handle: handle,
};
inner.cells.insert(key.to_string(), cell);
inner
.dirty
.push((key.to_string(), initial_value, DirtyKind::SeedIfAbsent));
inner.schedule_dirty_flush();
sig
}
pub fn signal_for<T>(&self, key: &SettingsKey<T>) -> Signal<T>
where
T: Clone + Serialize + DeserializeOwned + 'static,
{
self.signal(key.key, (key.default)())
}
fn try_existing<T: Clone + 'static>(&self, key: &str) -> Option<Signal<T>> {
let inner = self.inner.borrow();
let cell = inner.cells.get(key)?;
Some(downcast_or_panic::<T>(key, cell))
}
fn resync_with_disk(&self) -> Result<bool, SettingsStoreError> {
let current_stamp = disk_stamp(&self.path);
if current_stamp == self.inner.borrow().last_known_stamp.get() {
return Ok(false);
}
let raw_text = match fs::read_to_string(&self.path) {
Ok(s) => s,
Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(SettingsStoreError::Io(e)),
};
let parsed: toml::Value = if raw_text.trim().is_empty() {
empty_table()
} else {
toml::from_str(&raw_text).map_err(SettingsStoreError::Parse)?
};
let parsed = match parsed {
v @ toml::Value::Table(_) => v,
_ => empty_table(),
};
{
let mut inner = self.inner.borrow_mut();
if inner.raw == parsed {
inner.last_known_stamp.set(current_stamp);
return Ok(false);
}
inner.raw = parsed.clone();
inner.last_known_stamp.set(current_stamp);
}
{
let inner_ref = self.inner.borrow();
inner_ref.applying_external.set(true);
for (key, cell) in inner_ref.cells.iter() {
if let Some(value) = get_nested(&parsed, key) {
(cell.apply_external)(value);
}
}
inner_ref.applying_external.set(false);
}
Ok(true)
}
}
impl Reloadable for SettingsStore {
fn path(&self) -> &Path {
&self.path
}
fn reload_from_disk(&self) -> Result<bool, SettingsFileError> {
self.resync_with_disk().map_err(SettingsFileError::from)
}
}
impl std::fmt::Debug for SettingsStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let inner = self.inner.borrow();
f.debug_struct("SettingsStore")
.field("path", &self.path)
.field("registered_keys", &inner.cells.len())
.finish()
}
}
fn empty_table() -> toml::Value {
toml::Value::Table(toml::map::Map::new())
}
fn downcast_or_panic<T: Clone + 'static>(key: &str, cell: &SignalCell) -> Signal<T> {
if cell.type_id != TypeId::of::<T>() {
panic!(
"SettingsStore: key \"{key}\" was registered as {prev}, but \
signal::<{new}>(...) was called. Pick one type per key.",
prev = cell.type_name,
new = std::any::type_name::<T>(),
);
}
cell.signal
.downcast_ref::<Signal<T>>()
.expect("type id matched but downcast failed — teksilo-settings bug")
.clone()
}
fn serialize_to_value<T: Serialize>(value: &T) -> Result<toml::Value, toml::ser::Error> {
toml::Value::try_from(value)
}
fn get_nested<'a>(raw: &'a toml::Value, key: &str) -> Option<&'a toml::Value> {
let mut current = raw;
for part in key.split('.') {
current = current.get(part)?;
}
Some(current)
}
fn write_nested(raw: &mut toml::Value, key: &str, value: toml::Value) {
let parts: Vec<&str> = key.split('.').collect();
let last = parts.len() - 1;
let mut current = raw;
for (i, part) in parts.iter().enumerate() {
let table = current
.as_table_mut()
.expect("write_nested: path validated by check_path_shape, but encountered non-table");
if i == last {
table.insert((*part).to_string(), value);
return;
}
let entry = table
.entry((*part).to_string())
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
current = entry;
}
}
#[derive(Debug)]
enum CollisionKind {
IntermediateIsValue {
existing_path: String,
existing_kind: &'static str,
},
LeafIsTable { existing_path: String },
}
impl CollisionKind {
fn message_for(&self, requested_key: &str, requested_type: &str) -> String {
match self {
CollisionKind::IntermediateIsValue {
existing_path,
existing_kind,
} => format!(
"SettingsStore: cannot register key \"{requested_key}\" as {requested_type} — \
the prefix \"{existing_path}\" is already a {existing_kind} value. \
A key cannot be both a value and a parent.",
),
CollisionKind::LeafIsTable { existing_path } => format!(
"SettingsStore: cannot register key \"{requested_key}\" as {requested_type} — \
\"{existing_path}\" is already a table (parent of other keys). \
A key cannot be both a value and a parent.",
),
}
}
}
fn check_path_shape(raw: &toml::Value, key: &str) -> Result<(), CollisionKind> {
let parts: Vec<&str> = key.split('.').collect();
let last = parts.len() - 1;
let mut current = raw;
let mut walked = String::new();
for (i, part) in parts.iter().enumerate() {
if !walked.is_empty() {
walked.push('.');
}
walked.push_str(part);
let Some(child) = current.get(part) else {
return Ok(());
};
let is_last = i == last;
if is_last {
if child.is_table() {
return Err(CollisionKind::LeafIsTable {
existing_path: walked,
});
}
return Ok(());
}
if !child.is_table() {
return Err(CollisionKind::IntermediateIsValue {
existing_path: walked,
existing_kind: kind_name(child),
});
}
current = child;
}
Ok(())
}
fn kind_name(value: &toml::Value) -> &'static str {
match value {
toml::Value::String(_) => "string",
toml::Value::Integer(_) => "integer",
toml::Value::Float(_) => "float",
toml::Value::Boolean(_) => "boolean",
toml::Value::Datetime(_) => "datetime",
toml::Value::Array(_) => "array",
toml::Value::Table(_) => "table",
}
}
impl SettingsStore {
pub fn open_path(path: &Path) -> Result<Self, SettingsStoreError> {
Self::open(path.to_path_buf())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
use tempfile::tempdir;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
struct Window {
x: i32,
y: i32,
title: String,
}
fn open_in(dir: &Path, name: &str) -> SettingsStore {
SettingsStore::open_with_delay(dir.join(name), Duration::ZERO).unwrap()
}
#[test]
fn signal_returns_default_when_key_absent() {
let dir = tempdir().unwrap();
let store = open_in(dir.path(), "store.toml");
let sig = store.signal::<f32>("editor.font_size", 14.0);
assert_eq!(sig.get(), 14.0);
}
#[test]
fn signal_dedupes_per_key() {
let dir = tempdir().unwrap();
let store = open_in(dir.path(), "store.toml");
let a = store.signal::<f32>("editor.font_size", 14.0);
let b = store.signal::<f32>("editor.font_size", 99.0); assert_eq!(b.get(), 14.0);
a.set(22.0);
assert_eq!(b.get(), 22.0);
}
#[test]
fn set_persists_after_flush_now_and_reopens() {
let dir = tempdir().unwrap();
let path = dir.path().join("p.toml");
{
let store = SettingsStore::open_with_delay(path.clone(), Duration::ZERO).unwrap();
let sig = store.signal::<f32>("editor.font_size", 14.0);
sig.set(18.0);
store.flush_now().unwrap();
}
let store = SettingsStore::open_with_delay(path, Duration::ZERO).unwrap();
let sig = store.signal::<f32>("editor.font_size", 14.0);
assert_eq!(sig.get(), 18.0);
}
#[test]
#[should_panic(expected = "struct values serialize as TOML tables")]
fn struct_values_rejected_at_registration() {
let dir = tempdir().unwrap();
let store = open_in(dir.path(), "p.toml");
let _w = store.signal::<Window>(
"window.main",
Window {
x: 0,
y: 0,
title: String::new(),
},
);
}
#[test]
fn array_of_scalars_roundtrip() {
let dir = tempdir().unwrap();
let path = dir.path().join("p.toml");
{
let store = SettingsStore::open_with_delay(path.clone(), Duration::ZERO).unwrap();
let palette =
store.signal::<Vec<String>>("ui.palette", vec!["red".into(), "blue".into()]);
palette.set(vec!["green".into(), "yellow".into(), "purple".into()]);
store.flush_now().unwrap();
}
let store = SettingsStore::open_with_delay(path, Duration::ZERO).unwrap();
let palette = store.signal::<Vec<String>>("ui.palette", vec![]);
assert_eq!(palette.get(), vec!["green", "yellow", "purple"]);
}
#[test]
#[should_panic(expected = "registered as f32")]
fn type_mismatch_panics() {
let dir = tempdir().unwrap();
let store = open_in(dir.path(), "p.toml");
let _a = store.signal::<f32>("k", 1.0);
let _b = store.signal::<i32>("k", 2);
}
#[test]
#[should_panic(expected = "is already a string value")]
fn intermediate_value_collision_panics() {
let dir = tempdir().unwrap();
let store = open_in(dir.path(), "p.toml");
let _ = store.signal::<String>("editor", "blue".into());
let _ = store.signal::<f32>("editor.font_size", 14.0);
}
#[test]
#[should_panic(expected = "is already a table")]
fn leaf_table_collision_panics() {
let dir = tempdir().unwrap();
let store = open_in(dir.path(), "p.toml");
let _ = store.signal::<f32>("editor.font_size", 14.0);
let _ = store.signal::<String>("editor", "blue".into());
}
#[test]
fn deeply_nested_collision_caught() {
let dir = tempdir().unwrap();
let path = dir.path().join("p.toml");
fs::write(&path, "[a.b]\nc = 5\n").unwrap();
let store = SettingsStore::open_with_delay(path, Duration::ZERO).unwrap();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
store.signal::<i32>("a.b", 0);
}));
assert!(result.is_err());
}
#[test]
fn registered_keys_lists_touched_keys_only() {
let dir = tempdir().unwrap();
let store = open_in(dir.path(), "p.toml");
assert!(store.registered_keys().is_empty());
let _ = store.signal::<i32>("a", 1);
let _ = store.signal::<bool>("b.c", true);
let mut keys = store.registered_keys();
keys.sort();
assert_eq!(keys, vec!["a".to_string(), "b.c".to_string()]);
assert!(store.has("a"));
assert!(!store.has("nonexistent"));
}
#[test]
fn signal_for_uses_constant_default() {
const HEIGHT: SettingsKey<f32> = SettingsKey::new("layout.height", || 42.0);
let dir = tempdir().unwrap();
let store = open_in(dir.path(), "p.toml");
let sig = store.signal_for(&HEIGHT);
assert_eq!(sig.get(), 42.0);
}
#[test]
fn dropping_store_does_not_leak_via_observer() {
let dir = tempdir().unwrap();
let store = open_in(dir.path(), "p.toml");
let weak = {
let inner_rc = Rc::clone(&store.inner);
let weak = Rc::downgrade(&inner_rc);
let _sig = store.signal::<f32>("k", 1.0);
drop(inner_rc);
weak
};
assert!(weak.upgrade().is_some());
drop(store);
assert!(
weak.upgrade().is_none(),
"observer must not keep StoreInner alive via a strong capture"
);
}
#[test]
fn observer_writes_back_to_raw() {
let dir = tempdir().unwrap();
let path = dir.path().join("p.toml");
let store = SettingsStore::open_with_delay(path.clone(), Duration::ZERO).unwrap();
let sig = store.signal::<i32>("answer", 0);
sig.set(42);
store.flush_now().unwrap();
let on_disk = fs::read_to_string(&path).unwrap();
assert!(on_disk.contains("answer = 42"));
}
#[test]
fn pre_existing_file_seeds_signals() {
let dir = tempdir().unwrap();
let path = dir.path().join("p.toml");
fs::write(&path, "[editor]\nfont_size = 18.0\n").unwrap();
let store = SettingsStore::open_with_delay(path, Duration::ZERO).unwrap();
let sig = store.signal::<f32>("editor.font_size", 1.0);
assert_eq!(sig.get(), 18.0);
}
#[test]
fn path_with_top_level_scalar_recovers_with_empty_table() {
let dir = tempdir().unwrap();
let path = dir.path().join("p.toml");
fs::write(&path, "").unwrap();
let store = SettingsStore::open_with_delay(path, Duration::ZERO).unwrap();
let sig = store.signal::<i32>("k", 7);
assert_eq!(sig.get(), 7);
}
#[test]
fn two_concurrent_stores_each_setting_a_different_key_both_survive_and_reload_updates_live_signal()
{
let dir = tempdir().unwrap();
let path = dir.path().join("shared_store.toml");
let a = SettingsStore::open_with_delay(path.clone(), Duration::ZERO).unwrap();
let b = SettingsStore::open_with_delay(path.clone(), Duration::ZERO).unwrap();
let dark_a = a.signal::<bool>("ui.dark", false);
let dark_b = b.signal::<bool>("ui.dark", false);
let width_b = b.signal::<f32>("editor.column_width", 80.0);
assert!(!dark_b.get(), "b hasn't seen a's write yet");
dark_a.set(true);
a.flush_now().unwrap();
assert!(Reloadable::reload_from_disk(&b).unwrap());
assert!(dark_b.get(), "b's live signal must reflect a's write");
width_b.set(120.0);
b.flush_now().unwrap();
let c = SettingsStore::open_with_delay(path, Duration::ZERO).unwrap();
assert!(c.signal::<bool>("ui.dark", false).get());
assert_eq!(c.signal::<f32>("editor.column_width", 80.0).get(), 120.0);
}
#[test]
fn reload_driven_set_schedules_no_write() {
let dir = tempdir().unwrap();
let path = dir.path().join("no_bounce.toml");
let a = SettingsStore::open_with_delay(path.clone(), Duration::ZERO).unwrap();
let b = SettingsStore::open_with_delay(path.clone(), Duration::ZERO).unwrap();
let _dark_b = b.signal::<bool>("ui.dark", false);
a.signal::<bool>("ui.dark", false).set(true);
a.flush_now().unwrap();
assert!(Reloadable::reload_from_disk(&b).unwrap());
assert!(
b.inner.borrow().dirty.is_empty(),
"a reload-driven set must not enqueue a write-back"
);
}
#[test]
fn reload_from_disk_returns_false_and_touches_nothing_when_unchanged() {
let dir = tempdir().unwrap();
let path = dir.path().join("unchanged_store.toml");
let store = SettingsStore::open_with_delay(path, Duration::ZERO).unwrap();
let sig = store.signal::<f32>("k", 1.0);
sig.set(2.0);
store.flush_now().unwrap();
assert!(!Reloadable::reload_from_disk(&store).unwrap());
assert_eq!(sig.get(), 2.0);
}
#[test]
fn reload_from_disk_ignores_our_own_last_write_via_cheap_stamp_check() {
let dir = tempdir().unwrap();
let path = dir.path().join("self_write_store.toml");
let store = SettingsStore::open_with_delay(path, Duration::ZERO).unwrap();
store.signal::<f32>("k", 1.0).set(9.0);
store.flush_now().unwrap();
assert!(!Reloadable::reload_from_disk(&store).unwrap());
}
}