use std::borrow::Borrow;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::time::Duration;
use serde::Serialize;
use serde::de::DeserializeOwned;
use teksilo_data::ListModel;
use crate::collection::list::{Keyed, PersistedListModel};
use crate::file::SettingsFileError;
use crate::migration::Migrator;
use crate::path::AppPaths;
use crate::reload::Reloadable;
use crate::store::DEFAULT_DEBOUNCE;
pub trait MruEntry: Keyed + Clone + Serialize + DeserializeOwned + Send + 'static {
fn is_pinned(&self) -> bool {
false
}
fn set_pinned(&mut self, _pinned: bool) {}
fn touch(&mut self) {}
}
pub struct MruList<T: MruEntry> {
persisted: Rc<PersistedListModel<T>>,
max_items: usize,
}
impl<T: MruEntry> Clone for MruList<T> {
fn clone(&self) -> Self {
Self {
persisted: Rc::clone(&self.persisted),
max_items: self.max_items,
}
}
}
impl<T: MruEntry> MruList<T> {
pub fn open(paths: &AppPaths, name: &str, max_items: usize) -> Result<Self, SettingsFileError> {
Self::open_with_delay(paths, name, max_items, DEFAULT_DEBOUNCE)
}
pub fn open_with_delay(
paths: &AppPaths,
name: &str,
max_items: usize,
delay: Duration,
) -> Result<Self, SettingsFileError> {
Self::open_at(paths.config_file(name), max_items, delay)
}
pub fn open_at(
path: PathBuf,
max_items: usize,
delay: Duration,
) -> Result<Self, SettingsFileError> {
let persisted = PersistedListModel::open(path, delay, Migrator::new())?;
Ok(Self {
persisted: Rc::new(persisted),
max_items,
})
}
pub fn model(&self) -> &ListModel<T> {
self.persisted.model()
}
pub fn max_items(&self) -> usize {
self.max_items
}
pub fn add(&self, mut entry: T) {
let key = entry.key();
let was_pinned = self.find_index(&key).is_some_and(|idx| {
self.persisted
.model()
.with_item(idx, |t| t.is_pinned())
.unwrap_or(false)
});
if was_pinned && !entry.is_pinned() {
entry.set_pinned(true);
}
entry.touch();
self.persisted.upsert_front(entry);
self.cap_to_max();
}
pub fn remove<Q>(&self, key: &Q)
where
T::Key: Borrow<Q>,
Q: Eq + ?Sized,
{
if let Some(idx) = self.find_index_by(key) {
let owned_key = self
.persisted
.model()
.with_item(idx, |t| t.key())
.expect("index was just found to be valid");
self.persisted.remove(&owned_key);
}
}
pub fn touch<Q>(&self, key: &Q)
where
T::Key: Borrow<Q>,
Q: Eq + ?Sized,
{
if let Some(idx) = self.find_index_by(key) {
let model = self.persisted.model();
let mut updated = match model.with_item(idx, |t| t.clone()) {
Some(v) => v,
None => return,
};
updated.touch();
self.persisted.update_in_place(updated);
}
}
pub fn set_pinned<Q>(&self, key: &Q, pinned: bool)
where
T::Key: Borrow<Q>,
Q: Eq + ?Sized,
{
if let Some(idx) = self.find_index_by(key) {
let model = self.persisted.model();
let mut updated = match model.with_item(idx, |t| t.clone()) {
Some(v) => v,
None => return,
};
updated.set_pinned(pinned);
self.persisted.update_in_place(updated);
}
}
pub fn is_pinned<Q>(&self, key: &Q) -> bool
where
T::Key: Borrow<Q>,
Q: Eq + ?Sized,
{
match self.find_index_by(key) {
Some(idx) => self
.persisted
.model()
.with_item(idx, |t| t.is_pinned())
.unwrap_or(false),
None => false,
}
}
pub fn clear(&self) {
self.persisted.clear();
}
pub fn flush_now(&self) -> Result<(), SettingsFileError> {
self.persisted.flush_now()
}
pub fn path(&self) -> &Path {
self.persisted.path()
}
fn find_index(&self, key: &T::Key) -> Option<usize> {
self.find_index_by(key)
}
fn find_index_by<Q>(&self, key: &Q) -> Option<usize>
where
T::Key: Borrow<Q>,
Q: Eq + ?Sized,
{
let model = self.persisted.model();
(0..model.len()).find(|&i| {
model
.with_item(i, |t| t.key().borrow() == key)
.unwrap_or(false)
})
}
fn cap_to_max(&self) {
let model = self.persisted.model();
let len = model.len();
let mut unpinned = 0usize;
for i in 0..len {
if model.with_item(i, |t| !t.is_pinned()).unwrap_or(false) {
unpinned += 1;
}
}
if unpinned <= self.max_items {
return;
}
let mut to_drop = unpinned - self.max_items;
let mut i = len;
while i > 0 && to_drop > 0 {
i -= 1;
let evict = model.with_item(i, |t| (!t.is_pinned()).then(|| t.key()));
if let Some(Some(key)) = evict {
self.persisted.remove(&key);
to_drop -= 1;
}
}
}
}
impl<T: MruEntry> Reloadable for MruList<T>
where
T: PartialEq,
{
fn path(&self) -> &Path {
MruList::path(self)
}
fn reload_from_disk(&self) -> Result<bool, SettingsFileError> {
self.persisted.reload_from_disk()
}
}
impl<T: MruEntry> std::fmt::Debug for MruList<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MruList")
.field("len", &self.persisted.model().len())
.field("max_items", &self.max_items)
.field("path", &self.persisted.path())
.field("entry_type", &std::any::type_name::<T>())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
use tempfile::tempdir;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
struct DemoItem {
path: PathBuf,
name: String,
opened_at: u64,
pinned: bool,
}
impl DemoItem {
fn new(path: &str, name: &str) -> Self {
Self {
path: path.into(),
name: name.into(),
opened_at: 0,
pinned: false,
}
}
fn pinned(mut self) -> Self {
self.pinned = true;
self
}
}
impl Keyed for DemoItem {
type Key = PathBuf;
fn key(&self) -> PathBuf {
self.path.clone()
}
}
impl MruEntry for DemoItem {
fn is_pinned(&self) -> bool {
self.pinned
}
fn set_pinned(&mut self, p: bool) {
self.pinned = p;
}
fn touch(&mut self) {
self.opened_at += 1;
}
}
fn open(dir: &Path, max: usize) -> MruList<DemoItem> {
let paths = AppPaths::for_testing(dir);
MruList::open_with_delay(&paths, "mru", max, Duration::ZERO).unwrap()
}
#[test]
fn add_pushes_to_front() {
let dir = tempdir().unwrap();
let mru = open(dir.path(), 5);
mru.add(DemoItem::new("/a", "A"));
mru.add(DemoItem::new("/b", "B"));
assert_eq!(mru.model().len(), 2);
assert_eq!(mru.model().with_item(0, |i| i.name.clone()).unwrap(), "B");
}
#[test]
fn add_dedupes_by_key() {
let dir = tempdir().unwrap();
let mru = open(dir.path(), 5);
mru.add(DemoItem::new("/a", "A"));
mru.add(DemoItem::new("/b", "B"));
mru.add(DemoItem::new("/a", "A again"));
assert_eq!(mru.model().len(), 2);
assert_eq!(
mru.model().with_item(0, |i| i.name.clone()).unwrap(),
"A again"
);
assert_eq!(mru.model().with_item(1, |i| i.name.clone()).unwrap(), "B");
}
#[test]
fn add_preserves_pin_on_dedupe() {
let dir = tempdir().unwrap();
let mru = open(dir.path(), 5);
mru.add(DemoItem::new("/a", "A").pinned());
mru.add(DemoItem::new("/a", "A renamed")); assert!(mru.model().with_item(0, |i| i.pinned).unwrap());
}
#[test]
fn touch_invokes_entry_hook() {
let dir = tempdir().unwrap();
let mru = open(dir.path(), 5);
mru.add(DemoItem::new("/a", "A")); let before = mru.model().with_item(0, |i| i.opened_at).unwrap();
mru.touch(Path::new("/a"));
let after = mru.model().with_item(0, |i| i.opened_at).unwrap();
assert!(after > before);
}
#[test]
fn cap_drops_oldest_unpinned() {
let dir = tempdir().unwrap();
let mru = open(dir.path(), 2);
mru.add(DemoItem::new("/a", "A"));
mru.add(DemoItem::new("/b", "B"));
mru.add(DemoItem::new("/c", "C"));
let names: Vec<String> = (0..mru.model().len())
.map(|i| mru.model().with_item(i, |x| x.name.clone()).unwrap())
.collect();
assert_eq!(names, vec!["C", "B"]);
}
#[test]
fn pinned_survives_cap() {
let dir = tempdir().unwrap();
let mru = open(dir.path(), 2);
mru.add(DemoItem::new("/a", "A").pinned());
mru.add(DemoItem::new("/b", "B"));
mru.add(DemoItem::new("/c", "C"));
mru.add(DemoItem::new("/d", "D"));
let mut names: Vec<String> = (0..mru.model().len())
.map(|i| mru.model().with_item(i, |x| x.name.clone()).unwrap())
.collect();
names.sort();
assert_eq!(names, vec!["A", "C", "D"]);
}
#[test]
fn set_pinned_sets_state_and_is_idempotent() {
let dir = tempdir().unwrap();
let mru = open(dir.path(), 5);
mru.add(DemoItem::new("/a", "A"));
assert!(!mru.model().with_item(0, |i| i.pinned).unwrap());
mru.set_pinned(Path::new("/a"), true);
assert!(mru.model().with_item(0, |i| i.pinned).unwrap());
mru.set_pinned(Path::new("/a"), true);
assert!(mru.model().with_item(0, |i| i.pinned).unwrap());
mru.set_pinned(Path::new("/a"), false);
assert!(!mru.model().with_item(0, |i| i.pinned).unwrap());
}
#[test]
fn remove_drops_entry() {
let dir = tempdir().unwrap();
let mru = open(dir.path(), 5);
mru.add(DemoItem::new("/a", "A"));
mru.add(DemoItem::new("/b", "B"));
mru.remove(Path::new("/a"));
assert_eq!(mru.model().len(), 1);
}
#[test]
fn persists_across_reopen() {
let dir = tempdir().unwrap();
{
let mru = open(dir.path(), 5);
mru.add(DemoItem::new("/foo", "Foo"));
mru.add(DemoItem::new("/bar", "Bar"));
mru.flush_now().unwrap();
}
let mru = open(dir.path(), 5);
assert_eq!(mru.model().len(), 2);
assert_eq!(mru.model().with_item(0, |i| i.name.clone()).unwrap(), "Bar");
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
struct StringItem {
token: String,
count: u32,
}
impl Keyed for StringItem {
type Key = String;
fn key(&self) -> String {
self.token.clone()
}
}
impl MruEntry for StringItem {}
#[test]
fn works_with_string_keyed_entries() {
let dir = tempdir().unwrap();
let paths = AppPaths::for_testing(dir.path());
let mru: MruList<StringItem> =
MruList::open_with_delay(&paths, "tokens", 3, Duration::ZERO).unwrap();
mru.add(StringItem {
token: "alpha".into(),
count: 1,
});
mru.add(StringItem {
token: "beta".into(),
count: 2,
});
mru.add(StringItem {
token: "alpha".into(),
count: 99,
});
assert_eq!(mru.model().len(), 2);
mru.remove("beta");
assert_eq!(mru.model().len(), 1);
}
#[test]
fn two_peers_each_adding_a_different_recent_both_survive() {
let dir = tempdir().unwrap();
let paths = AppPaths::for_testing(dir.path());
let a: MruList<DemoItem> =
MruList::open_with_delay(&paths, "recents", 10, Duration::ZERO).unwrap();
let b: MruList<DemoItem> =
MruList::open_with_delay(&paths, "recents", 10, Duration::ZERO).unwrap();
a.add(DemoItem::new("/proj/a", "Project A"));
a.flush_now().unwrap();
b.add(DemoItem::new("/proj/b", "Project B"));
b.flush_now().unwrap();
let c: MruList<DemoItem> =
MruList::open_with_delay(&paths, "recents", 10, Duration::ZERO).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!["Project A".to_string(), "Project B".to_string()],
"both peers' additions must survive — neither is silently lost"
);
}
#[test]
fn reload_from_disk_picks_up_a_peers_addition() {
let dir = tempdir().unwrap();
let paths = AppPaths::for_testing(dir.path());
let a: MruList<DemoItem> =
MruList::open_with_delay(&paths, "recents", 10, Duration::ZERO).unwrap();
let b: MruList<DemoItem> =
MruList::open_with_delay(&paths, "recents", 10, Duration::ZERO).unwrap();
a.add(DemoItem::new("/proj/a", "Project A"));
a.flush_now().unwrap();
assert!(Reloadable::reload_from_disk(&b).unwrap());
assert_eq!(b.model().len(), 1);
}
}