1use std::hash::Hash;
70use std::path::{Path, PathBuf};
71use std::time::{Duration, SystemTime};
72
73use serde::de::DeserializeOwned;
74use serde::{Deserialize, Serialize};
75use teksilo_data::ListModel;
76
77use crate::file::{SettingsFileError, disk_stamp, quarantine, read_toml_with_retry};
78use crate::flush::{DebouncedWriter, FlushError};
79use crate::lock::FileLock;
80use crate::migration::{Migrator, Versioned};
81use crate::reload::Reloadable;
82
83pub trait Keyed {
91 type Key: Eq + Hash + Clone + Send + 'static;
93
94 fn key(&self) -> Self::Key;
98}
99
100#[derive(Debug, Clone)]
105pub enum ListOp<T: Keyed> {
106 UpsertFront(T),
112 UpdateInPlace(T),
116 Remove(T::Key),
118 Clear,
120}
121
122#[derive(Serialize, Deserialize, Debug, Clone)]
125pub struct ListFile<T> {
126 #[serde(default = "default_version")]
129 pub version: u32,
130 #[serde(default = "Vec::new")]
132 pub items: Vec<T>,
133}
134
135fn default_version() -> u32 {
136 1
137}
138
139impl<T> Default for ListFile<T> {
140 fn default() -> Self {
141 Self {
142 version: 1,
143 items: Vec::new(),
144 }
145 }
146}
147
148impl<T: 'static> Versioned for ListFile<T> {
154 const CURRENT_VERSION: u32 = 1;
155 fn version(&self) -> u32 {
156 self.version
157 }
158 fn set_version(&mut self, v: u32) {
159 self.version = v;
160 }
161}
162
163pub struct PersistedListModel<T>
167where
168 T: Keyed + Clone + Serialize + DeserializeOwned + Send + 'static,
169{
170 model: ListModel<T>,
171 writer: DebouncedWriter,
172 migrator: Migrator<ListFile<T>>,
176 last_known_stamp: std::cell::Cell<(Option<SystemTime>, Option<u64>)>,
180}
181
182impl<T> PersistedListModel<T>
183where
184 T: Keyed + Clone + Serialize + DeserializeOwned + Send + 'static,
185{
186 pub fn open(
196 path: PathBuf,
197 delay: Duration,
198 migrator: Migrator<ListFile<T>>,
199 ) -> Result<Self, SettingsFileError> {
200 let lock = FileLock::acquire_exclusive(&path).map_err(SettingsFileError::Io)?;
201 let file = match read_list_or_default(&path, &migrator) {
202 Ok(f) => f,
203 Err(other) => {
204 quarantine(&path);
205 eprintln!(
206 "teksilo-settings: load failed for {}: {}; falling back to an empty list",
207 path.display(),
208 other,
209 );
210 ListFile::default()
211 }
212 };
213 let stamp = disk_stamp(&path);
214 drop(lock);
215
216 let model = ListModel::from_vec(file.items);
217 let writer = DebouncedWriter::new(path, delay);
218
219 Ok(Self {
220 model,
221 writer,
222 migrator,
223 last_known_stamp: std::cell::Cell::new(stamp),
224 })
225 }
226
227 pub fn model(&self) -> &ListModel<T> {
232 &self.model
233 }
234
235 pub fn upsert_front(&self, item: T) {
239 if let Some(idx) = self.find_index(&item.key()) {
240 self.model.remove(idx);
241 }
242 self.model.insert(0, item.clone());
243 self.schedule_op(ListOp::UpsertFront(item));
244 }
245
246 pub fn update_in_place(&self, item: T) -> bool {
250 let Some(idx) = self.find_index(&item.key()) else {
251 return false;
252 };
253 self.model.set(idx, item.clone());
254 self.schedule_op(ListOp::UpdateInPlace(item));
255 true
256 }
257
258 pub fn remove(&self, key: &T::Key) -> bool {
262 let Some(idx) = self.find_index(key) else {
263 return false;
264 };
265 self.model.remove(idx);
266 self.schedule_op(ListOp::Remove(key.clone()));
267 true
268 }
269
270 pub fn clear(&self) {
272 self.model.clear();
273 self.schedule_op(ListOp::Clear);
274 }
275
276 pub fn flush_now(&self) -> Result<(), SettingsFileError> {
282 self.writer.flush_now().map_err(SettingsFileError::Flush)?;
283 self.last_known_stamp.set(disk_stamp(self.writer.path()));
284 Ok(())
285 }
286
287 pub fn path(&self) -> &Path {
289 self.writer.path()
290 }
291
292 fn find_index(&self, key: &T::Key) -> Option<usize> {
293 let model = &self.model;
294 (0..model.len()).find(|&i| model.with_item(i, |t| t.key() == *key).unwrap_or(false))
295 }
296
297 fn schedule_op(&self, op: ListOp<T>) {
298 let migrator = self.migrator.clone();
299 let patch: crate::flush::Patch = Box::new(move |current: Option<String>| {
300 let file = parse_list_file_text(current.as_deref(), &migrator)
301 .map_err(|e| FlushError::Merge(e.to_string()))?;
302 let mut items = file.items;
303 apply_list_op(&mut items, &op);
304 let new_file = ListFile {
305 version: <ListFile<T> as Versioned>::CURRENT_VERSION,
306 items,
307 };
308 toml::to_string_pretty(&new_file).map_err(|e| FlushError::Merge(e.to_string()))
309 });
310 self.writer.schedule(patch);
311 }
312}
313
314fn apply_list_op<T: Keyed + Clone>(items: &mut Vec<T>, op: &ListOp<T>) {
318 match op {
319 ListOp::UpsertFront(item) => {
320 let key = item.key();
321 items.retain(|t| t.key() != key);
322 items.insert(0, item.clone());
323 }
324 ListOp::UpdateInPlace(item) => {
325 let key = item.key();
326 if let Some(slot) = items.iter_mut().find(|t| t.key() == key) {
327 *slot = item.clone();
328 }
329 }
331 ListOp::Remove(key) => {
332 items.retain(|t| t.key() != *key);
333 }
334 ListOp::Clear => {
335 items.clear();
336 }
337 }
338}
339
340fn read_list_or_default<T>(
344 path: &Path,
345 migrator: &Migrator<ListFile<T>>,
346) -> Result<ListFile<T>, SettingsFileError>
347where
348 T: Clone + Serialize + DeserializeOwned + 'static,
349{
350 match read_toml_with_retry(path)? {
351 Some(raw) => {
352 let mut file = migrator.run(raw).map_err(SettingsFileError::Migrate)?;
353 file.version = <ListFile<T> as Versioned>::CURRENT_VERSION;
354 Ok(file)
355 }
356 None => Ok(ListFile::default()),
357 }
358}
359
360fn parse_list_file_text<T>(
366 text: Option<&str>,
367 migrator: &Migrator<ListFile<T>>,
368) -> Result<ListFile<T>, SettingsFileError>
369where
370 T: Clone + Serialize + DeserializeOwned + 'static,
371{
372 match text {
373 Some(text) => {
374 let raw: toml::Value = toml::from_str(text).map_err(SettingsFileError::Parse)?;
375 let mut file = migrator.run(raw).map_err(SettingsFileError::Migrate)?;
376 file.version = <ListFile<T> as Versioned>::CURRENT_VERSION;
377 Ok(file)
378 }
379 None => Ok(ListFile::default()),
380 }
381}
382
383impl<T> Reloadable for PersistedListModel<T>
384where
385 T: Keyed + Clone + Serialize + DeserializeOwned + Send + PartialEq + 'static,
386{
387 fn path(&self) -> &Path {
388 PersistedListModel::path(self)
389 }
390
391 fn reload_from_disk(&self) -> Result<bool, SettingsFileError> {
392 if let Err(e) = self.writer.flush_now() {
412 eprintln!(
413 "teksilo-settings: pre-reload flush of {} failed: {e}; reloading anyway",
414 self.writer.path().display(),
415 );
416 }
417
418 let path = self.writer.path();
419 let current_stamp = disk_stamp(path);
420 if current_stamp == self.last_known_stamp.get() {
421 return Ok(false);
422 }
423
424 let file = read_list_or_default(path, &self.migrator)?;
425 self.last_known_stamp.set(current_stamp);
426
427 let current: Vec<T> = (0..self.model.len())
428 .filter_map(|i| self.model.with_item(i, |t| t.clone()))
429 .collect();
430 if current == file.items {
431 return Ok(false);
432 }
433
434 self.model.reconcile_by_key(file.items, |t| t.key());
435 Ok(true)
436 }
437}
438
439impl<T> std::fmt::Debug for PersistedListModel<T>
440where
441 T: Keyed + Clone + Serialize + DeserializeOwned + Send + 'static,
442{
443 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
444 f.debug_struct("PersistedListModel")
445 .field("path", &self.writer.path())
446 .field("len", &self.model.len())
447 .finish()
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454 use serde::{Deserialize, Serialize};
455 use std::collections::HashSet;
456 use std::fs;
457 use tempfile::tempdir;
458
459 #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
460 struct Item {
461 name: String,
462 count: i32,
463 }
464
465 impl Keyed for Item {
466 type Key = String;
467 fn key(&self) -> String {
468 self.name.clone()
469 }
470 }
471
472 fn item(name: &str, count: i32) -> Item {
473 Item {
474 name: name.into(),
475 count,
476 }
477 }
478
479 #[test]
480 fn fresh_file_starts_empty() {
481 let dir = tempdir().unwrap();
482 let path = dir.path().join("list.toml");
483 let plm: PersistedListModel<Item> =
484 PersistedListModel::open(path, Duration::ZERO, Migrator::new()).unwrap();
485 assert_eq!(plm.model().len(), 0);
486 }
487
488 #[test]
489 fn upsert_front_persists_and_reopens() {
490 let dir = tempdir().unwrap();
491 let path = dir.path().join("list.toml");
492
493 {
494 let plm: PersistedListModel<Item> =
495 PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
496 plm.upsert_front(item("a", 1));
497 plm.upsert_front(item("b", 2));
498 plm.flush_now().unwrap();
499 }
500
501 let plm: PersistedListModel<Item> =
502 PersistedListModel::open(path, Duration::ZERO, Migrator::new()).unwrap();
503 assert_eq!(plm.model().len(), 2);
504 assert_eq!(
505 plm.model().with_item(0, |x| x.clone()).unwrap(),
506 item("b", 2)
507 );
508 assert_eq!(
509 plm.model().with_item(1, |x| x.clone()).unwrap(),
510 item("a", 1)
511 );
512 }
513
514 #[test]
515 fn upsert_front_dedupes_by_key() {
516 let dir = tempdir().unwrap();
517 let path = dir.path().join("list.toml");
518 let plm: PersistedListModel<Item> =
519 PersistedListModel::open(path, Duration::ZERO, Migrator::new()).unwrap();
520
521 plm.upsert_front(item("a", 1));
522 plm.upsert_front(item("b", 2));
523 plm.upsert_front(item("a", 99));
524
525 assert_eq!(plm.model().len(), 2);
526 assert_eq!(
527 plm.model().with_item(0, |x| x.clone()).unwrap(),
528 item("a", 99)
529 );
530 assert_eq!(
531 plm.model().with_item(1, |x| x.clone()).unwrap(),
532 item("b", 2)
533 );
534 }
535
536 #[test]
537 fn update_in_place_does_not_reorder() {
538 let dir = tempdir().unwrap();
539 let path = dir.path().join("list.toml");
540 let plm: PersistedListModel<Item> =
541 PersistedListModel::open(path, Duration::ZERO, Migrator::new()).unwrap();
542
543 plm.upsert_front(item("a", 1));
544 plm.upsert_front(item("b", 2));
545 assert!(plm.update_in_place(item("a", 42)));
546
547 assert_eq!(
548 plm.model().with_item(0, |x| x.clone()).unwrap(),
549 item("b", 2)
550 );
551 assert_eq!(
552 plm.model().with_item(1, |x| x.clone()).unwrap(),
553 item("a", 42)
554 );
555 }
556
557 #[test]
558 fn update_in_place_returns_false_for_missing_key() {
559 let dir = tempdir().unwrap();
560 let path = dir.path().join("list.toml");
561 let plm: PersistedListModel<Item> =
562 PersistedListModel::open(path, Duration::ZERO, Migrator::new()).unwrap();
563 assert!(!plm.update_in_place(item("ghost", 0)));
564 }
565
566 #[test]
567 fn remove_drops_entry_and_persists() {
568 let dir = tempdir().unwrap();
569 let path = dir.path().join("list.toml");
570 let plm: PersistedListModel<Item> =
571 PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
572
573 plm.upsert_front(item("a", 1));
574 plm.upsert_front(item("b", 2));
575 assert!(plm.remove(&"a".to_string()));
576 plm.flush_now().unwrap();
577
578 let raw = fs::read_to_string(&path).unwrap();
579 let parsed: ListFile<Item> = toml::from_str(&raw).unwrap();
580 assert_eq!(parsed.items.len(), 1);
581 assert_eq!(parsed.items[0].name, "b");
582 }
583
584 #[test]
585 fn clear_empties_and_persists() {
586 let dir = tempdir().unwrap();
587 let path = dir.path().join("list.toml");
588 let plm: PersistedListModel<Item> =
589 PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
590 plm.upsert_front(item("a", 1));
591 plm.clear();
592 plm.flush_now().unwrap();
593
594 let raw = fs::read_to_string(&path).unwrap();
595 let parsed: ListFile<Item> = toml::from_str(&raw).unwrap();
596 assert!(parsed.items.is_empty());
597 }
598
599 #[test]
610 fn two_concurrent_handles_each_adding_a_different_entry_both_survive() {
611 let dir = tempdir().unwrap();
612 let path = dir.path().join("shared_list.toml");
613
614 let a: PersistedListModel<Item> =
615 PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
616 let b: PersistedListModel<Item> =
617 PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
618
619 a.upsert_front(item("alpha", 1));
620 a.flush_now().unwrap();
621 b.upsert_front(item("beta", 2));
622 b.flush_now().unwrap();
623
624 let c: PersistedListModel<Item> =
626 PersistedListModel::open(path, Duration::ZERO, Migrator::new()).unwrap();
627 let mut names: Vec<String> = (0..c.model().len())
628 .map(|i| c.model().with_item(i, |x| x.name.clone()).unwrap())
629 .collect();
630 names.sort();
631 assert_eq!(names, vec!["alpha".to_string(), "beta".to_string()]);
632 }
633
634 #[test]
639 fn a_peers_addition_is_not_erased_by_a_later_unrelated_flush() {
640 let dir = tempdir().unwrap();
641 let path = dir.path().join("no_clobber.toml");
642
643 let a: PersistedListModel<Item> =
644 PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
645 let b: PersistedListModel<Item> =
646 PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
647
648 a.upsert_front(item("from-a", 1));
649 a.flush_now().unwrap();
650
651 b.upsert_front(item("from-b", 2));
654 b.flush_now().unwrap();
655
656 let raw = fs::read_to_string(&path).unwrap();
657 let parsed: ListFile<Item> = toml::from_str(&raw).unwrap();
658 let names: HashSet<String> = parsed.items.iter().map(|i| i.name.clone()).collect();
659 assert!(names.contains("from-a"), "a's entry must survive");
660 assert!(names.contains("from-b"), "b's entry must be present too");
661 }
662
663 #[test]
664 fn multiple_ops_in_one_debounce_window_all_land() {
665 let dir = tempdir().unwrap();
666 let path = dir.path().join("burst.toml");
667 let plm: PersistedListModel<Item> =
668 PersistedListModel::open(path, Duration::from_millis(200), Migrator::new()).unwrap();
669
670 for i in 0..5 {
671 plm.upsert_front(item(&format!("item{i}"), i));
672 }
673 plm.flush_now().unwrap();
674 assert_eq!(plm.model().len(), 5);
675 }
676
677 #[test]
682 fn reload_from_disk_picks_up_a_peers_addition() {
683 let dir = tempdir().unwrap();
684 let path = dir.path().join("reload_list.toml");
685
686 let a: PersistedListModel<Item> =
687 PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
688 let b: PersistedListModel<Item> =
689 PersistedListModel::open(path, Duration::ZERO, Migrator::new()).unwrap();
690
691 a.upsert_front(item("peer-item", 1));
692 a.flush_now().unwrap();
693
694 assert!(Reloadable::reload_from_disk(&b).unwrap());
695 assert_eq!(b.model().len(), 1);
696 assert_eq!(
697 b.model().with_item(0, |x| x.name.clone()).unwrap(),
698 "peer-item"
699 );
700 }
701
702 #[test]
703 fn reload_from_disk_returns_false_when_unchanged() {
704 let dir = tempdir().unwrap();
705 let path = dir.path().join("reload_unchanged.toml");
706 let a: PersistedListModel<Item> =
707 PersistedListModel::open(path, Duration::ZERO, Migrator::new()).unwrap();
708 assert!(!Reloadable::reload_from_disk(&a).unwrap());
709 }
710
711 #[test]
712 fn reload_from_disk_preserves_positions_of_unrelated_items() {
713 let dir = tempdir().unwrap();
717 let path = dir.path().join("reload_stable.toml");
718
719 let a: PersistedListModel<Item> =
720 PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
721 a.upsert_front(item("first", 1));
722 a.upsert_front(item("second", 2));
723 a.flush_now().unwrap();
724
725 let b: PersistedListModel<Item> =
726 PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
727 assert_eq!(
728 b.model().with_item(0, |x| x.name.clone()).unwrap(),
729 "second"
730 );
731 assert_eq!(b.model().with_item(1, |x| x.name.clone()).unwrap(), "first");
732
733 a.upsert_front(item("third", 3));
734 a.flush_now().unwrap();
735
736 assert!(Reloadable::reload_from_disk(&b).unwrap());
737 assert_eq!(b.model().len(), 3);
738 assert_eq!(b.model().with_item(0, |x| x.name.clone()).unwrap(), "third");
739 assert_eq!(
740 b.model().with_item(1, |x| x.name.clone()).unwrap(),
741 "second"
742 );
743 assert_eq!(b.model().with_item(2, |x| x.name.clone()).unwrap(), "first");
744 }
745
746 #[test]
753 fn reload_from_disk_does_not_revert_a_local_not_yet_flushed_change() {
754 let dir = tempdir().unwrap();
755 let path = dir.path().join("f14.toml");
756
757 let seed: PersistedListModel<Item> =
761 PersistedListModel::open(path.clone(), Duration::ZERO, Migrator::new()).unwrap();
762 seed.upsert_front(item("peer-baseline", 0));
763 seed.flush_now().unwrap();
764 drop(seed);
765
766 let a: PersistedListModel<Item> =
767 PersistedListModel::open(path.clone(), Duration::from_secs(3600), Migrator::new())
768 .unwrap();
769 assert_eq!(a.model().len(), 1);
770
771 a.upsert_front(item("X", 1));
774 assert_eq!(
775 a.model().with_item(0, |x| x.name.clone()).unwrap(),
776 "X",
777 "X must be at the front in memory right away"
778 );
779
780 let peer_file = ListFile {
783 version: 1,
784 items: vec![item("peer-baseline", 0), item("peer-new", 2)],
785 };
786 fs::write(&path, toml::to_string_pretty(&peer_file).unwrap()).unwrap();
787
788 let changed = Reloadable::reload_from_disk(&a).unwrap();
793 assert!(changed, "the peer's write must be observed as a change");
794
795 let names_after: Vec<String> = (0..a.model().len())
796 .map(|i| a.model().with_item(i, |x| x.name.clone()).unwrap())
797 .collect();
798 assert!(
799 names_after.contains(&"X".to_string()),
800 "a's own not-yet-flushed change must survive reload_from_disk, got {names_after:?}"
801 );
802 assert!(
803 names_after.contains(&"peer-new".to_string()),
804 "the peer's concurrent addition must also be present, got {names_after:?}"
805 );
806
807 let raw = fs::read_to_string(&path).unwrap();
811 let parsed: ListFile<Item> = toml::from_str(&raw).unwrap();
812 let on_disk: HashSet<String> = parsed.items.iter().map(|i| i.name.clone()).collect();
813 assert!(on_disk.contains("X"), "X must have reached disk too");
814 assert!(
815 on_disk.contains("peer-new"),
816 "the peer's entry must still be on disk too"
817 );
818 }
819}