use std::hash::Hash;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use teksilo_data::ListModel;
use crate::file::{SettingsFileError, disk_stamp, quarantine, read_toml_with_retry};
use crate::flush::{DebouncedWriter, FlushError};
use crate::lock::FileLock;
use crate::migration::{Migrator, Versioned};
use crate::reload::Reloadable;
pub trait Keyed {
type Key: Eq + Hash + Clone + Send + 'static;
fn key(&self) -> Self::Key;
}
#[derive(Debug, Clone)]
pub enum ListOp<T: Keyed> {
UpsertFront(T),
UpdateInPlace(T),
Remove(T::Key),
Clear,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ListFile<T> {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default = "Vec::new")]
pub items: Vec<T>,
}
fn default_version() -> u32 {
1
}
impl<T> Default for ListFile<T> {
fn default() -> Self {
Self {
version: 1,
items: Vec::new(),
}
}
}
impl<T: 'static> Versioned for ListFile<T> {
const CURRENT_VERSION: u32 = 1;
fn version(&self) -> u32 {
self.version
}
fn set_version(&mut self, v: u32) {
self.version = v;
}
}
pub struct PersistedListModel<T>
where
T: Keyed + Clone + Serialize + DeserializeOwned + Send + 'static,
{
model: ListModel<T>,
writer: DebouncedWriter,
migrator: Migrator<ListFile<T>>,
last_known_stamp: std::cell::Cell<(Option<SystemTime>, Option<u64>)>,
}
impl<T> PersistedListModel<T>
where
T: Keyed + Clone + Serialize + DeserializeOwned + Send + 'static,
{
pub fn open(
path: PathBuf,
delay: Duration,
migrator: Migrator<ListFile<T>>,
) -> Result<Self, SettingsFileError> {
let lock = FileLock::acquire_exclusive(&path).map_err(SettingsFileError::Io)?;
let file = match read_list_or_default(&path, &migrator) {
Ok(f) => f,
Err(other) => {
quarantine(&path);
eprintln!(
"teksilo-settings: load failed for {}: {}; falling back to an empty list",
path.display(),
other,
);
ListFile::default()
}
};
let stamp = disk_stamp(&path);
drop(lock);
let model = ListModel::from_vec(file.items);
let writer = DebouncedWriter::new(path, delay);
Ok(Self {
model,
writer,
migrator,
last_known_stamp: std::cell::Cell::new(stamp),
})
}
pub fn model(&self) -> &ListModel<T> {
&self.model
}
pub fn upsert_front(&self, item: T) {
if let Some(idx) = self.find_index(&item.key()) {
self.model.remove(idx);
}
self.model.insert(0, item.clone());
self.schedule_op(ListOp::UpsertFront(item));
}
pub fn update_in_place(&self, item: T) -> bool {
let Some(idx) = self.find_index(&item.key()) else {
return false;
};
self.model.set(idx, item.clone());
self.schedule_op(ListOp::UpdateInPlace(item));
true
}
pub fn remove(&self, key: &T::Key) -> bool {
let Some(idx) = self.find_index(key) else {
return false;
};
self.model.remove(idx);
self.schedule_op(ListOp::Remove(key.clone()));
true
}
pub fn clear(&self) {
self.model.clear();
self.schedule_op(ListOp::Clear);
}
pub fn flush_now(&self) -> Result<(), SettingsFileError> {
self.writer.flush_now().map_err(SettingsFileError::Flush)?;
self.last_known_stamp.set(disk_stamp(self.writer.path()));
Ok(())
}
pub fn path(&self) -> &Path {
self.writer.path()
}
fn find_index(&self, key: &T::Key) -> Option<usize> {
let model = &self.model;
(0..model.len()).find(|&i| model.with_item(i, |t| t.key() == *key).unwrap_or(false))
}
fn schedule_op(&self, op: ListOp<T>) {
let migrator = self.migrator.clone();
let patch: crate::flush::Patch = Box::new(move |current: Option<String>| {
let file = parse_list_file_text(current.as_deref(), &migrator)
.map_err(|e| FlushError::Merge(e.to_string()))?;
let mut items = file.items;
apply_list_op(&mut items, &op);
let new_file = ListFile {
version: <ListFile<T> as Versioned>::CURRENT_VERSION,
items,
};
toml::to_string_pretty(&new_file).map_err(|e| FlushError::Merge(e.to_string()))
});
self.writer.schedule(patch);
}
}
fn apply_list_op<T: Keyed + Clone>(items: &mut Vec<T>, op: &ListOp<T>) {
match op {
ListOp::UpsertFront(item) => {
let key = item.key();
items.retain(|t| t.key() != key);
items.insert(0, item.clone());
}
ListOp::UpdateInPlace(item) => {
let key = item.key();
if let Some(slot) = items.iter_mut().find(|t| t.key() == key) {
*slot = item.clone();
}
}
ListOp::Remove(key) => {
items.retain(|t| t.key() != *key);
}
ListOp::Clear => {
items.clear();
}
}
}
fn read_list_or_default<T>(
path: &Path,
migrator: &Migrator<ListFile<T>>,
) -> Result<ListFile<T>, SettingsFileError>
where
T: Clone + Serialize + DeserializeOwned + 'static,
{
match read_toml_with_retry(path)? {
Some(raw) => {
let mut file = migrator.run(raw).map_err(SettingsFileError::Migrate)?;
file.version = <ListFile<T> as Versioned>::CURRENT_VERSION;
Ok(file)
}
None => Ok(ListFile::default()),
}
}
fn parse_list_file_text<T>(
text: Option<&str>,
migrator: &Migrator<ListFile<T>>,
) -> Result<ListFile<T>, SettingsFileError>
where
T: Clone + Serialize + DeserializeOwned + 'static,
{
match text {
Some(text) => {
let raw: toml::Value = toml::from_str(text).map_err(SettingsFileError::Parse)?;
let mut file = migrator.run(raw).map_err(SettingsFileError::Migrate)?;
file.version = <ListFile<T> as Versioned>::CURRENT_VERSION;
Ok(file)
}
None => Ok(ListFile::default()),
}
}
impl<T> Reloadable for PersistedListModel<T>
where
T: Keyed + Clone + Serialize + DeserializeOwned + Send + PartialEq + 'static,
{
fn path(&self) -> &Path {
PersistedListModel::path(self)
}
fn reload_from_disk(&self) -> Result<bool, SettingsFileError> {
if let Err(e) = self.writer.flush_now() {
eprintln!(
"teksilo-settings: pre-reload flush of {} failed: {e}; reloading anyway",
self.writer.path().display(),
);
}
let path = self.writer.path();
let current_stamp = disk_stamp(path);
if current_stamp == self.last_known_stamp.get() {
return Ok(false);
}
let file = read_list_or_default(path, &self.migrator)?;
self.last_known_stamp.set(current_stamp);
let current: Vec<T> = (0..self.model.len())
.filter_map(|i| self.model.with_item(i, |t| t.clone()))
.collect();
if current == file.items {
return Ok(false);
}
self.model.reconcile_by_key(file.items, |t| t.key());
Ok(true)
}
}
impl<T> std::fmt::Debug for PersistedListModel<T>
where
T: Keyed + Clone + Serialize + DeserializeOwned + Send + 'static,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PersistedListModel")
.field("path", &self.writer.path())
.field("len", &self.model.len())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs;
use tempfile::tempdir;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
struct Item {
name: String,
count: i32,
}
impl Keyed for Item {
type Key = String;
fn key(&self) -> String {
self.name.clone()
}
}
fn item(name: &str, count: i32) -> Item {
Item {
name: name.into(),
count,
}
}
#[test]
fn fresh_file_starts_empty() {
let dir = tempdir().unwrap();
let path = dir.path().join("list.toml");
let plm: PersistedListModel<Item> =
PersistedListModel::open(path, Duration::ZERO, Migrator::new()).unwrap();
assert_eq!(plm.model().len(), 0);
}
#[test]
fn upsert_front_persists_and_reopens() {
let dir = tempdir().unwrap();
let path = dir.path().join("list.toml");
{
let plm: PersistedListModel<Item> =
PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
plm.upsert_front(item("a", 1));
plm.upsert_front(item("b", 2));
plm.flush_now().unwrap();
}
let plm: PersistedListModel<Item> =
PersistedListModel::open(path, Duration::ZERO, Migrator::new()).unwrap();
assert_eq!(plm.model().len(), 2);
assert_eq!(
plm.model().with_item(0, |x| x.clone()).unwrap(),
item("b", 2)
);
assert_eq!(
plm.model().with_item(1, |x| x.clone()).unwrap(),
item("a", 1)
);
}
#[test]
fn upsert_front_dedupes_by_key() {
let dir = tempdir().unwrap();
let path = dir.path().join("list.toml");
let plm: PersistedListModel<Item> =
PersistedListModel::open(path, Duration::ZERO, Migrator::new()).unwrap();
plm.upsert_front(item("a", 1));
plm.upsert_front(item("b", 2));
plm.upsert_front(item("a", 99));
assert_eq!(plm.model().len(), 2);
assert_eq!(
plm.model().with_item(0, |x| x.clone()).unwrap(),
item("a", 99)
);
assert_eq!(
plm.model().with_item(1, |x| x.clone()).unwrap(),
item("b", 2)
);
}
#[test]
fn update_in_place_does_not_reorder() {
let dir = tempdir().unwrap();
let path = dir.path().join("list.toml");
let plm: PersistedListModel<Item> =
PersistedListModel::open(path, Duration::ZERO, Migrator::new()).unwrap();
plm.upsert_front(item("a", 1));
plm.upsert_front(item("b", 2));
assert!(plm.update_in_place(item("a", 42)));
assert_eq!(
plm.model().with_item(0, |x| x.clone()).unwrap(),
item("b", 2)
);
assert_eq!(
plm.model().with_item(1, |x| x.clone()).unwrap(),
item("a", 42)
);
}
#[test]
fn update_in_place_returns_false_for_missing_key() {
let dir = tempdir().unwrap();
let path = dir.path().join("list.toml");
let plm: PersistedListModel<Item> =
PersistedListModel::open(path, Duration::ZERO, Migrator::new()).unwrap();
assert!(!plm.update_in_place(item("ghost", 0)));
}
#[test]
fn remove_drops_entry_and_persists() {
let dir = tempdir().unwrap();
let path = dir.path().join("list.toml");
let plm: PersistedListModel<Item> =
PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
plm.upsert_front(item("a", 1));
plm.upsert_front(item("b", 2));
assert!(plm.remove(&"a".to_string()));
plm.flush_now().unwrap();
let raw = fs::read_to_string(&path).unwrap();
let parsed: ListFile<Item> = toml::from_str(&raw).unwrap();
assert_eq!(parsed.items.len(), 1);
assert_eq!(parsed.items[0].name, "b");
}
#[test]
fn clear_empties_and_persists() {
let dir = tempdir().unwrap();
let path = dir.path().join("list.toml");
let plm: PersistedListModel<Item> =
PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
plm.upsert_front(item("a", 1));
plm.clear();
plm.flush_now().unwrap();
let raw = fs::read_to_string(&path).unwrap();
let parsed: ListFile<Item> = toml::from_str(&raw).unwrap();
assert!(parsed.items.is_empty());
}
#[test]
fn two_concurrent_handles_each_adding_a_different_entry_both_survive() {
let dir = tempdir().unwrap();
let path = dir.path().join("shared_list.toml");
let a: PersistedListModel<Item> =
PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
let b: PersistedListModel<Item> =
PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
a.upsert_front(item("alpha", 1));
a.flush_now().unwrap();
b.upsert_front(item("beta", 2));
b.flush_now().unwrap();
let c: PersistedListModel<Item> =
PersistedListModel::open(path, Duration::ZERO, Migrator::new()).unwrap();
let mut names: Vec<String> = (0..c.model().len())
.map(|i| c.model().with_item(i, |x| x.name.clone()).unwrap())
.collect();
names.sort();
assert_eq!(names, vec!["alpha".to_string(), "beta".to_string()]);
}
#[test]
fn a_peers_addition_is_not_erased_by_a_later_unrelated_flush() {
let dir = tempdir().unwrap();
let path = dir.path().join("no_clobber.toml");
let a: PersistedListModel<Item> =
PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
let b: PersistedListModel<Item> =
PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
a.upsert_front(item("from-a", 1));
a.flush_now().unwrap();
b.upsert_front(item("from-b", 2));
b.flush_now().unwrap();
let raw = fs::read_to_string(&path).unwrap();
let parsed: ListFile<Item> = toml::from_str(&raw).unwrap();
let names: HashSet<String> = parsed.items.iter().map(|i| i.name.clone()).collect();
assert!(names.contains("from-a"), "a's entry must survive");
assert!(names.contains("from-b"), "b's entry must be present too");
}
#[test]
fn multiple_ops_in_one_debounce_window_all_land() {
let dir = tempdir().unwrap();
let path = dir.path().join("burst.toml");
let plm: PersistedListModel<Item> =
PersistedListModel::open(path, Duration::from_millis(200), Migrator::new()).unwrap();
for i in 0..5 {
plm.upsert_front(item(&format!("item{i}"), i));
}
plm.flush_now().unwrap();
assert_eq!(plm.model().len(), 5);
}
#[test]
fn reload_from_disk_picks_up_a_peers_addition() {
let dir = tempdir().unwrap();
let path = dir.path().join("reload_list.toml");
let a: PersistedListModel<Item> =
PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
let b: PersistedListModel<Item> =
PersistedListModel::open(path, Duration::ZERO, Migrator::new()).unwrap();
a.upsert_front(item("peer-item", 1));
a.flush_now().unwrap();
assert!(Reloadable::reload_from_disk(&b).unwrap());
assert_eq!(b.model().len(), 1);
assert_eq!(
b.model().with_item(0, |x| x.name.clone()).unwrap(),
"peer-item"
);
}
#[test]
fn reload_from_disk_returns_false_when_unchanged() {
let dir = tempdir().unwrap();
let path = dir.path().join("reload_unchanged.toml");
let a: PersistedListModel<Item> =
PersistedListModel::open(path, Duration::ZERO, Migrator::new()).unwrap();
assert!(!Reloadable::reload_from_disk(&a).unwrap());
}
#[test]
fn reload_from_disk_preserves_positions_of_unrelated_items() {
let dir = tempdir().unwrap();
let path = dir.path().join("reload_stable.toml");
let a: PersistedListModel<Item> =
PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
a.upsert_front(item("first", 1));
a.upsert_front(item("second", 2));
a.flush_now().unwrap();
let b: PersistedListModel<Item> =
PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
assert_eq!(
b.model().with_item(0, |x| x.name.clone()).unwrap(),
"second"
);
assert_eq!(b.model().with_item(1, |x| x.name.clone()).unwrap(), "first");
a.upsert_front(item("third", 3));
a.flush_now().unwrap();
assert!(Reloadable::reload_from_disk(&b).unwrap());
assert_eq!(b.model().len(), 3);
assert_eq!(b.model().with_item(0, |x| x.name.clone()).unwrap(), "third");
assert_eq!(
b.model().with_item(1, |x| x.name.clone()).unwrap(),
"second"
);
assert_eq!(b.model().with_item(2, |x| x.name.clone()).unwrap(), "first");
}
#[test]
fn reload_from_disk_does_not_revert_a_local_not_yet_flushed_change() {
let dir = tempdir().unwrap();
let path = dir.path().join("f14.toml");
let seed: PersistedListModel<Item> =
PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
seed.upsert_front(item("peer-baseline", 0));
seed.flush_now().unwrap();
drop(seed);
let a: PersistedListModel<Item> =
PersistedListModel::open(path.clone(), Duration::from_secs(3600), Migrator::new())
.unwrap();
assert_eq!(a.model().len(), 1);
a.upsert_front(item("X", 1));
assert_eq!(
a.model().with_item(0, |x| x.name.clone()).unwrap(),
"X",
"X must be at the front in memory right away"
);
let peer_file = ListFile {
version: 1,
items: vec![item("peer-baseline", 0), item("peer-new", 2)],
};
fs::write(&path, toml::to_string_pretty(&peer_file).unwrap()).unwrap();
let changed = Reloadable::reload_from_disk(&a).unwrap();
assert!(changed, "the peer's write must be observed as a change");
let names_after: Vec<String> = (0..a.model().len())
.map(|i| a.model().with_item(i, |x| x.name.clone()).unwrap())
.collect();
assert!(
names_after.contains(&"X".to_string()),
"a's own not-yet-flushed change must survive reload_from_disk, got {names_after:?}"
);
assert!(
names_after.contains(&"peer-new".to_string()),
"the peer's concurrent addition must also be present, got {names_after:?}"
);
let raw = fs::read_to_string(&path).unwrap();
let parsed: ListFile<Item> = toml::from_str(&raw).unwrap();
let on_disk: HashSet<String> = parsed.items.iter().map(|i| i.name.clone()).collect();
assert!(on_disk.contains("X"), "X must have reached disk too");
assert!(
on_disk.contains("peer-new"),
"the peer's entry must still be on disk too"
);
}
}