use std::cell::{Cell, Ref, RefCell};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::Serialize;
use serde::de::DeserializeOwned;
use crate::flush::{FlushError, write_atomic};
use crate::lock::FileLock;
use crate::migration::{MigrationError, Migrator, Versioned};
use crate::reload::Reloadable;
pub(crate) const MAX_READ_ATTEMPTS: u32 = 5;
pub(crate) const READ_RETRY_DELAY: Duration = Duration::from_millis(5);
#[derive(Debug, thiserror::Error)]
pub enum SettingsFileError {
#[error("settings file I/O: {0}")]
Io(#[from] io::Error),
#[error("settings file parse: {0}")]
Parse(#[source] toml::de::Error),
#[error("settings file migration: {0}")]
Migrate(#[source] MigrationError),
#[error("settings file serialize: {0}")]
Serialize(#[source] toml::ser::Error),
#[error("settings file flush: {0}")]
Flush(#[source] FlushError),
}
struct Inner<T: Versioned + DeserializeOwned> {
current: RefCell<T>,
path: PathBuf,
last_known_stamp: Cell<(Option<SystemTime>, Option<u64>)>,
migrator: Migrator<T>,
}
pub struct SettingsFile<T: Versioned + DeserializeOwned> {
inner: Rc<Inner<T>>,
}
impl<T: Versioned + DeserializeOwned> Clone for SettingsFile<T> {
fn clone(&self) -> Self {
Self {
inner: Rc::clone(&self.inner),
}
}
}
impl<T> SettingsFile<T>
where
T: Versioned + Serialize + DeserializeOwned + Default + Clone + 'static,
{
pub fn load(path: PathBuf, migrator: Migrator<T>) -> Result<Self, SettingsFileError> {
let lock = FileLock::acquire_exclusive(&path).map_err(SettingsFileError::Io)?;
let initial = match Self::read_or_default(&path, &migrator) {
Ok(value) => value,
Err(SettingsFileError::Migrate(e)) => {
eprintln!(
"teksilo-settings: {} is on a schema this build cannot migrate ({}); using in-memory defaults for this session, file left untouched",
path.display(),
e,
);
let mut v = T::default();
v.set_version(T::CURRENT_VERSION);
v
}
Err(SettingsFileError::Io(e)) => {
eprintln!(
"teksilo-settings: could not read {} ({}); using in-memory defaults for this session, file left untouched",
path.display(),
e,
);
let mut v = T::default();
v.set_version(T::CURRENT_VERSION);
v
}
Err(other) => {
quarantine(&path);
eprintln!(
"teksilo-settings: load failed for {}: {}; quarantined, falling back to defaults",
path.display(),
other,
);
let mut v = T::default();
v.set_version(T::CURRENT_VERSION);
v
}
};
let stamp = disk_stamp(&path);
drop(lock);
Ok(Self::new_inner(path, initial, stamp, migrator))
}
pub fn load_strict(path: PathBuf, migrator: Migrator<T>) -> Result<Self, SettingsFileError> {
let lock = FileLock::acquire_exclusive(&path).map_err(SettingsFileError::Io)?;
let initial = Self::read_or_default(&path, &migrator)?;
let stamp = disk_stamp(&path);
drop(lock);
Ok(Self::new_inner(path, initial, stamp, migrator))
}
fn new_inner(
path: PathBuf,
initial: T,
stamp: (Option<SystemTime>, Option<u64>),
migrator: Migrator<T>,
) -> Self {
Self {
inner: Rc::new(Inner {
current: RefCell::new(initial),
path,
last_known_stamp: Cell::new(stamp),
migrator,
}),
}
}
fn read_or_default(path: &Path, migrator: &Migrator<T>) -> Result<T, SettingsFileError> {
match read_toml_with_retry(path)? {
Some(raw) => {
let mut value = migrator.run(raw).map_err(SettingsFileError::Migrate)?;
value.set_version(T::CURRENT_VERSION);
Ok(value)
}
None => {
let mut v = T::default();
v.set_version(T::CURRENT_VERSION);
Ok(v)
}
}
}
pub fn borrow(&self) -> Ref<'_, T> {
self.inner.current.borrow()
}
pub fn snapshot(&self) -> T {
self.inner.current.borrow().clone()
}
pub fn replace(&self, new: T) -> Result<(), SettingsFileError> {
self.locked_read_modify_write(move |v| *v = new)
}
pub fn mutate<F: FnOnce(&mut T)>(&self, f: F) -> Result<(), SettingsFileError> {
self.locked_read_modify_write(f)
}
fn locked_read_modify_write<F: FnOnce(&mut T)>(&self, f: F) -> Result<(), SettingsFileError> {
let path = self.inner.path.clone();
let lock = FileLock::acquire_exclusive(&path).map_err(SettingsFileError::Io)?;
let mut fresh = Self::read_or_default(&path, &self.inner.migrator)?;
f(&mut fresh);
fresh.set_version(T::CURRENT_VERSION);
let serialized = toml::to_string_pretty(&fresh).map_err(SettingsFileError::Serialize)?;
write_atomic(&path, &serialized).map_err(SettingsFileError::Io)?;
let new_stamp = disk_stamp(&path);
*self.inner.current.borrow_mut() = fresh;
self.inner.last_known_stamp.set(new_stamp);
drop(lock);
Ok(())
}
pub fn reload_if_stale(&self) -> Result<bool, SettingsFileError> {
let path = self.inner.path.as_path();
let current_stamp = disk_stamp(path);
if current_stamp == self.inner.last_known_stamp.get() {
return Ok(false);
}
let value = Self::read_or_default(path, &self.inner.migrator)?;
*self.inner.current.borrow_mut() = value;
self.inner.last_known_stamp.set(current_stamp);
Ok(true)
}
pub fn flush_now(&self) -> Result<(), SettingsFileError> {
Ok(())
}
pub fn path(&self) -> &Path {
self.inner.path.as_path()
}
}
impl<T> Reloadable for SettingsFile<T>
where
T: Versioned + Serialize + DeserializeOwned + Default + Clone + PartialEq + 'static,
{
fn path(&self) -> &Path {
SettingsFile::path(self)
}
fn reload_from_disk(&self) -> Result<bool, SettingsFileError> {
let path = self.inner.path.as_path();
let current_stamp = disk_stamp(path);
if current_stamp == self.inner.last_known_stamp.get() {
return Ok(false);
}
let value = Self::read_or_default(path, &self.inner.migrator)?;
self.inner.last_known_stamp.set(current_stamp);
if *self.inner.current.borrow() == value {
return Ok(false);
}
*self.inner.current.borrow_mut() = value;
Ok(true)
}
}
impl<T: Versioned + DeserializeOwned + std::fmt::Debug> std::fmt::Debug for SettingsFile<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SettingsFile")
.field("path", &self.inner.path)
.field("current", &*self.inner.current.borrow())
.finish()
}
}
pub(crate) fn disk_stamp(path: &Path) -> (Option<SystemTime>, Option<u64>) {
match fs::metadata(path) {
Ok(m) => (m.modified().ok(), Some(m.len())),
Err(_) => (None, None),
}
}
pub(crate) fn read_toml_with_retry(path: &Path) -> Result<Option<toml::Value>, SettingsFileError> {
let mut last_parse_err = None;
for attempt in 0..MAX_READ_ATTEMPTS {
match fs::read_to_string(path) {
Ok(text) => match toml::from_str::<toml::Value>(&text) {
Ok(v) => return Ok(Some(v)),
Err(e) => {
last_parse_err = Some(e);
if attempt + 1 < MAX_READ_ATTEMPTS {
thread::sleep(READ_RETRY_DELAY);
}
}
},
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(SettingsFileError::Io(e)),
}
}
Err(SettingsFileError::Parse(last_parse_err.unwrap()))
}
pub(crate) fn quarantine(path: &Path) {
if !path.exists() {
return;
}
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let mut quarantine_path = path.to_path_buf();
let new_name = match path.file_name() {
Some(name) => format!("{}.broken-{ts}", name.to_string_lossy()),
None => format!("settings.broken-{ts}"),
};
quarantine_path.set_file_name(new_name);
if let Err(e) = fs::rename(path, &quarantine_path) {
eprintln!(
"teksilo-settings: could not quarantine {} -> {}: {}",
path.display(),
quarantine_path.display(),
e,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
use tempfile::tempdir;
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)]
struct Settings {
version: u32,
font_size: f32,
theme: String,
}
impl Versioned for Settings {
const CURRENT_VERSION: u32 = 1;
fn version(&self) -> u32 {
self.version
}
fn set_version(&mut self, v: u32) {
self.version = v;
}
}
#[test]
fn load_creates_default_when_file_missing() {
let dir = tempdir().unwrap();
let path = dir.path().join("missing.toml");
let file: SettingsFile<Settings> = SettingsFile::load(path, Migrator::new()).unwrap();
assert_eq!(
file.snapshot(),
Settings {
version: 1,
font_size: 0.0,
theme: String::new(),
}
);
}
#[test]
fn replace_persists_immediately() {
let dir = tempdir().unwrap();
let path = dir.path().join("s.toml");
let file: SettingsFile<Settings> =
SettingsFile::load(path.clone(), Migrator::new()).unwrap();
file.replace(Settings {
version: 1,
font_size: 16.0,
theme: "dark".into(),
})
.unwrap();
let raw = fs::read_to_string(&path).unwrap();
let again: Settings = toml::from_str(&raw).unwrap();
assert_eq!(again.font_size, 16.0);
assert_eq!(again.theme, "dark");
}
#[test]
fn mutate_modifies_in_place() {
let dir = tempdir().unwrap();
let path = dir.path().join("s.toml");
let file: SettingsFile<Settings> = SettingsFile::load(path, Migrator::new()).unwrap();
file.mutate(|s| {
s.font_size = 22.0;
s.theme = "light".into();
})
.unwrap();
assert_eq!(file.snapshot().font_size, 22.0);
}
#[test]
fn corrupt_file_falls_back_to_default_and_quarantines() {
let dir = tempdir().unwrap();
let path = dir.path().join("broken.toml");
fs::write(&path, "this is = not valid TOML = at all").unwrap();
let file: SettingsFile<Settings> =
SettingsFile::load(path.clone(), Migrator::new()).unwrap();
assert_eq!(file.snapshot().version, 1);
assert!(
!path.exists(),
"the corrupt original should have been renamed away"
);
let entries: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.collect();
let has_quarantine = entries
.iter()
.any(|e| e.file_name().to_string_lossy().contains(".broken-"));
assert!(has_quarantine, "expected a .broken-<ts> file");
}
#[test]
fn migration_failure_does_not_quarantine_a_peers_newer_schema() {
let dir = tempdir().unwrap();
let path = dir.path().join("newer_schema.toml");
let original_contents = "version = 99\nfont_size = 12.0\ntheme = \"from-the-future\"\n";
fs::write(&path, original_contents).unwrap();
let file: SettingsFile<Settings> =
SettingsFile::load(path.clone(), Migrator::new()).unwrap();
assert_eq!(
file.snapshot(),
Settings {
version: Settings::CURRENT_VERSION,
..Settings::default()
}
);
assert!(path.exists(), "the peer's file must not be renamed away");
let on_disk = fs::read_to_string(&path).unwrap();
assert_eq!(
on_disk, original_contents,
"the peer's file must be byte-identical before and after load()"
);
let entries: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert!(
!entries
.iter()
.any(|e| e.file_name().to_string_lossy().contains(".broken-")),
"a migration failure must never produce a quarantine sibling"
);
}
#[test]
#[cfg(unix)]
fn io_error_does_not_quarantine() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let path = dir.path().join("unreadable.toml");
fs::write(&path, "version = 1\nfont_size = 1.0\ntheme = \"x\"\n").unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o000)).unwrap();
if fs::read_to_string(&path).is_ok() {
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
return;
}
let file: SettingsFile<Settings> =
SettingsFile::load(path.clone(), Migrator::new()).unwrap();
assert_eq!(
file.snapshot(),
Settings {
version: Settings::CURRENT_VERSION,
..Settings::default()
}
);
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
assert!(path.exists(), "an unreadable file must not be renamed away");
let entries: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert!(
!entries
.iter()
.any(|e| e.file_name().to_string_lossy().contains(".broken-")),
"an I/O error must never produce a quarantine sibling"
);
}
#[test]
fn load_strict_propagates_parse_error() {
let dir = tempdir().unwrap();
let path = dir.path().join("broken.toml");
fs::write(&path, "= = =").unwrap();
let result: Result<SettingsFile<Settings>, _> =
SettingsFile::load_strict(path, Migrator::new());
assert!(matches!(result, Err(SettingsFileError::Parse(_))));
}
#[test]
fn clones_share_state() {
let dir = tempdir().unwrap();
let path = dir.path().join("s.toml");
let a: SettingsFile<Settings> = SettingsFile::load(path, Migrator::new()).unwrap();
let b = a.clone();
a.mutate(|s| s.font_size = 99.0).unwrap();
assert_eq!(b.snapshot().font_size, 99.0);
}
#[test]
fn two_concurrent_handles_both_writes_survive() {
let dir = tempdir().unwrap();
let path = dir.path().join("shared.toml");
let a: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
let b: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
a.mutate(|s| s.font_size = 42.0).unwrap();
b.mutate(|s| s.theme = "solarized".into()).unwrap();
let c: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
let snapshot = c.snapshot();
assert_eq!(snapshot.font_size, 42.0, "a's write must survive");
assert_eq!(snapshot.theme, "solarized", "b's write must survive");
let raw = fs::read_to_string(&path).unwrap();
let on_disk: Settings = toml::from_str(&raw).unwrap();
assert_eq!(on_disk.font_size, 42.0);
assert_eq!(on_disk.theme, "solarized");
}
#[test]
fn two_non_synchronized_handles_no_longer_clobber_each_other() {
let dir = tempdir().unwrap();
let path = dir.path().join("nonshared.toml");
let a: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
let b: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
a.mutate(|s| s.font_size = 42.0).unwrap();
b.mutate(|s| s.theme = "solarized".into()).unwrap();
let raw = fs::read_to_string(&path).unwrap();
let on_disk: Settings = toml::from_str(&raw).unwrap();
assert_eq!(on_disk.theme, "solarized", "b's own write is present");
assert_eq!(
on_disk.font_size, 42.0,
"a's write must survive b's later, unrelated mutate"
);
}
#[test]
fn reload_if_stale_picks_up_a_peers_write_and_reports_no_change_when_unchanged() {
let dir = tempdir().unwrap();
let path = dir.path().join("reload.toml");
let a: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
let b: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
assert!(!b.reload_if_stale().unwrap());
a.mutate(|s| s.font_size = 7.0).unwrap();
assert!(b.reload_if_stale().unwrap(), "b should notice a's write");
assert_eq!(b.snapshot().font_size, 7.0);
assert!(!b.reload_if_stale().unwrap());
}
#[test]
fn round_trips_versioned_migration() {
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)]
struct Prefs {
version: u32,
name: String,
pinned: bool,
}
impl Versioned for Prefs {
const CURRENT_VERSION: u32 = 2;
fn version(&self) -> u32 {
self.version
}
fn set_version(&mut self, v: u32) {
self.version = v;
}
}
let dir = tempdir().unwrap();
let path = dir.path().join("migrated.toml");
fs::write(&path, "version = 1\nname = \"legacy\"\n").unwrap();
let migrator: Migrator<Prefs> = Migrator::new().step(1, |mut v| {
if let Some(t) = v.as_table_mut() {
t.insert("pinned".into(), toml::Value::Boolean(true));
}
Ok(v)
});
let file: SettingsFile<Prefs> = SettingsFile::load(path.clone(), migrator).unwrap();
let snapshot = file.snapshot();
assert_eq!(snapshot.version, 2);
assert_eq!(snapshot.name, "legacy");
assert!(snapshot.pinned);
file.mutate(|p| p.name = "renamed".into()).unwrap();
let raw = fs::read_to_string(&path).unwrap();
let on_disk: Prefs = toml::from_str(&raw).unwrap();
assert_eq!(on_disk.version, 2);
assert_eq!(on_disk.name, "renamed");
assert!(on_disk.pinned);
}
#[test]
fn replace_also_uses_the_locked_path() {
let dir = tempdir().unwrap();
let path = dir.path().join("replace.toml");
let a: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
let b: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
a.mutate(|s| s.font_size = 5.0).unwrap();
b.replace(Settings {
version: 1,
font_size: 9.0,
theme: "replaced".into(),
})
.unwrap();
let raw = fs::read_to_string(&path).unwrap();
let on_disk: Settings = toml::from_str(&raw).unwrap();
assert_eq!(on_disk.font_size, 9.0);
assert_eq!(on_disk.theme, "replaced");
}
#[test]
fn flush_now_is_a_harmless_no_op() {
let dir = tempdir().unwrap();
let path = dir.path().join("flush.toml");
let file: SettingsFile<Settings> = SettingsFile::load(path, Migrator::new()).unwrap();
file.mutate(|s| s.font_size = 1.0).unwrap();
file.flush_now().unwrap();
}
#[test]
fn flush_now_never_touches_the_shared_worker_even_under_concurrent_mutate() {
let dir = tempdir().unwrap();
let path = dir.path().join("no_worker.toml");
let a: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
let b: SettingsFile<Settings> = SettingsFile::load(path, Migrator::new()).unwrap();
a.mutate(|s| s.font_size = 5.0).unwrap();
assert!(b.flush_now().is_ok());
assert!(a.flush_now().is_ok());
}
#[test]
fn construct_and_drop_is_cheap_in_a_tight_loop() {
let dir = tempdir().unwrap();
let path = dir.path().join("drop_timing.toml");
let start = std::time::Instant::now();
for _ in 0..1000 {
let file: SettingsFile<Settings> =
SettingsFile::load(path.clone(), Migrator::new()).unwrap();
drop(file);
}
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(5),
"1000 construct/drop cycles took {elapsed:?}; \
this type must never register with the shared worker pool"
);
}
#[test]
fn reload_from_disk_picks_up_a_peers_write() {
let dir = tempdir().unwrap();
let path = dir.path().join("reloadable.toml");
let a: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
let b: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
a.mutate(|s| s.theme = "peer-write".into()).unwrap();
assert!(Reloadable::reload_from_disk(&b).unwrap());
assert_eq!(b.snapshot().theme, "peer-write");
}
#[test]
fn reload_from_disk_returns_false_and_touches_nothing_when_content_is_unchanged() {
let dir = tempdir().unwrap();
let path = dir.path().join("unchanged.toml");
let a: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
assert!(!Reloadable::reload_from_disk(&a).unwrap());
let b: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
b.replace(a.snapshot()).unwrap();
assert!(!Reloadable::reload_from_disk(&a).unwrap());
}
#[test]
fn reload_from_disk_self_write_suppression_no_reparse_needed_after_own_mutate() {
let dir = tempdir().unwrap();
let path = dir.path().join("self_write.toml");
let a: SettingsFile<Settings> = SettingsFile::load(path, Migrator::new()).unwrap();
a.mutate(|s| s.font_size = 3.0).unwrap();
assert!(!Reloadable::reload_from_disk(&a).unwrap());
assert_eq!(a.snapshot().font_size, 3.0);
}
#[test]
fn reload_from_disk_also_works_through_the_path_method() {
let dir = tempdir().unwrap();
let path = dir.path().join("path.toml");
let a: SettingsFile<Settings> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
assert_eq!(Reloadable::path(&a), path.as_path());
}
}