use std::path::PathBuf;
use serde::{Deserialize, Serialize};
pub const CAP: usize = 6;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Recent {
pub label: String,
pub locator: String,
}
pub struct Recents {
path: PathBuf,
entries: Vec<Recent>,
}
impl Recents {
pub fn load(path: impl Into<PathBuf>) -> Self {
let path = path.into();
let entries = std::fs::read_to_string(&path)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_default();
Self { path, entries }
}
pub fn default_path() -> PathBuf {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
.unwrap_or_else(|| PathBuf::from("."))
.join("infinite")
.join("recents.json")
}
pub fn entries(&self) -> &[Recent] {
&self.entries
}
pub fn visit(&mut self, label: &str, locator: &str) -> bool {
let locator = locator
.split(['#', '@'])
.next()
.unwrap_or(locator)
.trim_end_matches('/');
if locator.is_empty() || crate::home::is_home(locator) {
return false;
}
let label = if label.trim().is_empty() {
locator
} else {
label.trim()
};
if self
.entries
.first()
.is_some_and(|r| r.locator == locator && r.label == label)
{
return false; }
self.entries.retain(|r| r.locator != locator);
self.entries.insert(
0,
Recent {
label: label.to_string(),
locator: locator.to_string(),
},
);
self.entries.truncate(CAP);
self.save();
true
}
fn save(&self) {
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match serde_json::to_string_pretty(&self.entries) {
Ok(json) => {
if let Err(e) = std::fs::write(&self.path, json) {
tracing::warn!("recents: cannot write {}: {e}", self.path.display());
}
}
Err(e) => tracing::warn!("recents: serialize failed: {e}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_store(tag: &str) -> PathBuf {
let p =
std::env::temp_dir().join(format!("loom-recents-{}-{tag}.json", std::process::id()));
let _ = std::fs::remove_file(&p);
p
}
#[test]
fn visits_dedupe_reorder_cap_and_persist() {
let path = temp_store("basic");
let mut r = Recents::load(&path);
assert!(r.visit("The Nexus", "thread://pixygon.io#entry"));
assert!(r.visit("The Commons", "thread://commons.pixygon.io"));
assert!(r.visit("The Nexus", "thread://pixygon.io"));
assert_eq!(r.entries().len(), 2);
assert_eq!(r.entries()[0].locator, "thread://pixygon.io");
assert!(!r.visit("The Nexus", "thread://pixygon.io"));
assert!(!r.visit("Home", crate::home::HOME_LOCATOR));
for i in 0..10 {
r.visit(&format!("W{i}"), &format!("thread://w{i}.example"));
}
assert_eq!(r.entries().len(), CAP);
assert_eq!(r.entries()[0].label, "W9");
let reloaded = Recents::load(&path);
assert_eq!(reloaded.entries(), r.entries());
let _ = std::fs::remove_file(&path);
}
}