use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Bookmark {
pub label: String,
pub locator: String,
}
pub struct Bookmarks {
path: PathBuf,
entries: Vec<Bookmark>,
}
impl Bookmarks {
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("bookmarks.json")
}
pub fn entries(&self) -> &[Bookmark] {
&self.entries
}
pub fn contains(&self, locator: &str) -> bool {
self.entries.iter().any(|b| b.locator == locator)
}
pub fn add(&mut self, label: impl Into<String>, locator: impl Into<String>) {
let (label, locator) = (label.into(), locator.into());
if let Some(b) = self.entries.iter_mut().find(|b| b.locator == locator) {
b.label = label;
} else {
self.entries.push(Bookmark { label, locator });
}
self.save();
}
pub fn remove(&mut self, locator: &str) -> bool {
let before = self.entries.len();
self.entries.retain(|b| b.locator != locator);
let removed = self.entries.len() != before;
if removed {
self.save();
}
removed
}
pub fn toggle(&mut self, label: impl Into<String>, locator: impl Into<String>) -> bool {
let locator = locator.into();
if self.contains(&locator) {
self.remove(&locator);
false
} else {
self.add(label, locator);
true
}
}
fn save(&self) {
if let Some(dir) = self.path.parent() {
let _ = std::fs::create_dir_all(dir);
}
match serde_json::to_string_pretty(&self.entries) {
Ok(json) => {
if let Err(e) = std::fs::write(&self.path, json) {
tracing::warn!("bookmarks: cannot write {}: {e}", self.path.display());
}
}
Err(e) => tracing::warn!("bookmarks: serialize failed: {e}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_store(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("loom-bookmarks-{}-{tag}.json", std::process::id()))
}
#[test]
fn add_toggle_and_persist_across_loads() {
let path = temp_store("roundtrip");
let _ = std::fs::remove_file(&path);
let mut b = Bookmarks::load(&path);
assert!(b.entries().is_empty());
b.add("The Nexus", "thread://nexus.pixygon.io/nexus");
assert!(b.contains("thread://nexus.pixygon.io/nexus"));
assert!(
b.toggle("The Forge", "thread://pixiel.ai/forge"),
"first toggle bookmarks it"
);
assert!(
!b.toggle("The Forge", "thread://pixiel.ai/forge"),
"second removes it"
);
let again = Bookmarks::load(&path);
assert_eq!(again.entries().len(), 1);
assert_eq!(again.entries()[0].label, "The Nexus");
let _ = std::fs::remove_file(&path);
}
#[test]
fn re_adding_refreshes_the_label_without_duplicating() {
let path = temp_store("relabel");
let _ = std::fs::remove_file(&path);
let mut b = Bookmarks::load(&path);
b.add("Old Title", "thread://a/x");
b.add("New Title", "thread://a/x");
assert_eq!(b.entries().len(), 1);
assert_eq!(b.entries()[0].label, "New Title");
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_corrupt_store_degrades_to_empty_not_error() {
let path = temp_store("corrupt");
std::fs::write(&path, "not json at all {{{").unwrap();
let b = Bookmarks::load(&path);
assert!(b.entries().is_empty());
let _ = std::fs::remove_file(&path);
}
}