thread_engine/
bookmarks.rs1use std::path::PathBuf;
11
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct Bookmark {
18 pub label: String,
19 pub locator: String,
21}
22
23pub struct Bookmarks {
26 path: PathBuf,
27 entries: Vec<Bookmark>,
28}
29
30impl Bookmarks {
31 pub fn load(path: impl Into<PathBuf>) -> Self {
33 let path = path.into();
34 let entries = std::fs::read_to_string(&path)
35 .ok()
36 .and_then(|text| serde_json::from_str(&text).ok())
37 .unwrap_or_default();
38 Self { path, entries }
39 }
40
41 pub fn default_path() -> PathBuf {
45 std::env::var_os("XDG_CONFIG_HOME")
46 .map(PathBuf::from)
47 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
48 .unwrap_or_else(|| PathBuf::from("."))
49 .join("infinite")
50 .join("bookmarks.json")
51 }
52
53 pub fn entries(&self) -> &[Bookmark] {
54 &self.entries
55 }
56
57 pub fn contains(&self, locator: &str) -> bool {
58 self.entries.iter().any(|b| b.locator == locator)
59 }
60
61 pub fn add(&mut self, label: impl Into<String>, locator: impl Into<String>) {
63 let (label, locator) = (label.into(), locator.into());
64 if let Some(b) = self.entries.iter_mut().find(|b| b.locator == locator) {
65 b.label = label;
66 } else {
67 self.entries.push(Bookmark { label, locator });
68 }
69 self.save();
70 }
71
72 pub fn remove(&mut self, locator: &str) -> bool {
74 let before = self.entries.len();
75 self.entries.retain(|b| b.locator != locator);
76 let removed = self.entries.len() != before;
77 if removed {
78 self.save();
79 }
80 removed
81 }
82
83 pub fn toggle(&mut self, label: impl Into<String>, locator: impl Into<String>) -> bool {
86 let locator = locator.into();
87 if self.contains(&locator) {
88 self.remove(&locator);
89 false
90 } else {
91 self.add(label, locator);
92 true
93 }
94 }
95
96 fn save(&self) {
97 if let Some(dir) = self.path.parent() {
98 let _ = std::fs::create_dir_all(dir);
99 }
100 match serde_json::to_string_pretty(&self.entries) {
101 Ok(json) => {
102 if let Err(e) = std::fs::write(&self.path, json) {
103 tracing::warn!("bookmarks: cannot write {}: {e}", self.path.display());
104 }
105 }
106 Err(e) => tracing::warn!("bookmarks: serialize failed: {e}"),
107 }
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 fn temp_store(tag: &str) -> PathBuf {
116 std::env::temp_dir().join(format!("loom-bookmarks-{}-{tag}.json", std::process::id()))
117 }
118
119 #[test]
120 fn add_toggle_and_persist_across_loads() {
121 let path = temp_store("roundtrip");
122 let _ = std::fs::remove_file(&path);
123
124 let mut b = Bookmarks::load(&path);
125 assert!(b.entries().is_empty());
126 b.add("The Nexus", "thread://nexus.pixygon.io/nexus");
127 assert!(b.contains("thread://nexus.pixygon.io/nexus"));
128
129 assert!(
131 b.toggle("The Forge", "thread://pixiel.ai/forge"),
132 "first toggle bookmarks it"
133 );
134 assert!(
135 !b.toggle("The Forge", "thread://pixiel.ai/forge"),
136 "second removes it"
137 );
138
139 let again = Bookmarks::load(&path);
141 assert_eq!(again.entries().len(), 1);
142 assert_eq!(again.entries()[0].label, "The Nexus");
143
144 let _ = std::fs::remove_file(&path);
145 }
146
147 #[test]
148 fn re_adding_refreshes_the_label_without_duplicating() {
149 let path = temp_store("relabel");
150 let _ = std::fs::remove_file(&path);
151
152 let mut b = Bookmarks::load(&path);
153 b.add("Old Title", "thread://a/x");
154 b.add("New Title", "thread://a/x");
155 assert_eq!(b.entries().len(), 1);
156 assert_eq!(b.entries()[0].label, "New Title");
157
158 let _ = std::fs::remove_file(&path);
159 }
160
161 #[test]
162 fn a_corrupt_store_degrades_to_empty_not_error() {
163 let path = temp_store("corrupt");
164 std::fs::write(&path, "not json at all {{{").unwrap();
165 let b = Bookmarks::load(&path);
166 assert!(b.entries().is_empty());
167 let _ = std::fs::remove_file(&path);
168 }
169}