trail/plugin/
bookmarks.rs1use std::collections::BTreeMap;
13use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16use thiserror::Error;
17
18#[derive(Debug, Error)]
20pub enum BookmarkError {
21 #[error("failed to read bookmarks from {path}: {source}")]
23 Read {
24 path: PathBuf,
26 #[source]
28 source: std::io::Error,
29 },
30 #[error("failed to write bookmarks to {path}: {source}")]
32 Write {
33 path: PathBuf,
35 #[source]
37 source: std::io::Error,
38 },
39 #[error("failed to parse bookmarks TOML: {0}")]
41 Parse(#[from] toml::de::Error),
42 #[error("failed to serialize bookmarks TOML: {0}")]
44 Serialize(#[from] toml::ser::Error),
45}
46
47#[derive(Debug, Default, Serialize, Deserialize)]
49struct BookmarksFile {
50 #[serde(default)]
52 bookmarks: BTreeMap<String, String>,
53}
54
55#[derive(Debug)]
63pub struct BookmarkStore {
64 file_path: PathBuf,
66 map: BTreeMap<String, PathBuf>,
68}
69
70impl BookmarkStore {
71 pub fn open(file_path: PathBuf) -> Result<Self, BookmarkError> {
80 let map = if file_path.exists() {
81 let content = std::fs::read_to_string(&file_path).map_err(|e| BookmarkError::Read {
82 path: file_path.clone(),
83 source: e,
84 })?;
85 let parsed: BookmarksFile = toml::from_str(&content)?;
86 parsed
87 .bookmarks
88 .into_iter()
89 .map(|(k, v)| (k, PathBuf::from(v)))
90 .collect()
91 } else {
92 BTreeMap::new()
93 };
94
95 Ok(Self { file_path, map })
96 }
97
98 pub fn add(&mut self, name: String, path: PathBuf) -> Result<(), BookmarkError> {
106 self.map.insert(name, path);
107 self.save()
108 }
109
110 #[allow(dead_code)]
120 pub fn remove(&mut self, name: &str) -> Result<bool, BookmarkError> {
121 let existed = self.map.remove(name).is_some();
122 self.save()?;
123 Ok(existed)
124 }
125
126 pub fn get(&self, name: &str) -> Option<&Path> {
129 self.map.get(name).map(|p| p.as_path())
130 }
131
132 #[allow(dead_code)]
135 pub fn list(&self) -> impl Iterator<Item = (&str, &Path)> {
136 self.map.iter().map(|(k, v)| (k.as_str(), v.as_path()))
137 }
138
139 #[allow(dead_code)]
142 pub fn is_empty(&self) -> bool {
143 self.map.is_empty()
144 }
145
146 #[allow(dead_code)]
149 pub fn len(&self) -> usize {
150 self.map.len()
151 }
152
153 fn save(&self) -> Result<(), BookmarkError> {
162 if let Some(parent) = self.file_path.parent() {
164 std::fs::create_dir_all(parent).map_err(|e| BookmarkError::Write {
165 path: self.file_path.clone(),
166 source: e,
167 })?;
168 }
169
170 let on_disk = BookmarksFile {
171 bookmarks: self
172 .map
173 .iter()
174 .map(|(k, v)| (k.clone(), v.display().to_string()))
175 .collect(),
176 };
177
178 let toml_str = toml::to_string_pretty(&on_disk)?;
179 std::fs::write(&self.file_path, toml_str).map_err(|e| BookmarkError::Write {
180 path: self.file_path.clone(),
181 source: e,
182 })
183 }
184}
185
186#[allow(dead_code)]
193pub fn default_bookmark_path() -> PathBuf {
194 crate::paths::bookmarks_file()
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 fn temp_store() -> (tempfile::TempDir, BookmarkStore) {
202 let dir = tempfile::tempdir().expect("tempdir");
203 let path = dir.path().join("bookmarks.toml");
204 let store = BookmarkStore::open(path).expect("open");
205 (dir, store)
206 }
207
208 #[test]
209 fn empty_store_on_new_file() {
210 let (_dir, store) = temp_store();
211 assert!(store.is_empty());
212 assert_eq!(store.len(), 0);
213 }
214
215 #[test]
216 fn add_and_get() {
217 let (_dir, mut store) = temp_store();
218 store
219 .add("home".to_owned(), PathBuf::from("/home/user"))
220 .expect("add");
221 assert_eq!(store.get("home"), Some(Path::new("/home/user")));
222 assert_eq!(store.len(), 1);
223 }
224
225 #[test]
226 fn add_overwrites_existing() {
227 let (_dir, mut store) = temp_store();
228 store
229 .add("home".to_owned(), PathBuf::from("/home/old"))
230 .expect("add");
231 store
232 .add("home".to_owned(), PathBuf::from("/home/new"))
233 .expect("add");
234 assert_eq!(store.get("home"), Some(Path::new("/home/new")));
235 assert_eq!(store.len(), 1);
236 }
237
238 #[test]
239 fn remove_returns_true_when_existed() {
240 let (_dir, mut store) = temp_store();
241 store
242 .add("work".to_owned(), PathBuf::from("/work"))
243 .expect("add");
244 let removed = store.remove("work").expect("remove");
245 assert!(removed);
246 assert!(store.is_empty());
247 }
248
249 #[test]
250 fn remove_returns_false_when_absent() {
251 let (_dir, mut store) = temp_store();
252 let removed = store.remove("nope").expect("remove");
253 assert!(!removed);
254 }
255
256 #[test]
257 fn list_is_alphabetical() {
258 let (_dir, mut store) = temp_store();
259 store
260 .add("zoo".to_owned(), PathBuf::from("/zoo"))
261 .expect("add");
262 store
263 .add("alpha".to_owned(), PathBuf::from("/alpha"))
264 .expect("add");
265 store
266 .add("middle".to_owned(), PathBuf::from("/middle"))
267 .expect("add");
268 let names: Vec<_> = store.list().map(|(n, _)| n).collect();
269 assert_eq!(names, vec!["alpha", "middle", "zoo"]);
270 }
271
272 #[test]
273 fn persists_across_reopen() {
274 let dir = tempfile::tempdir().expect("tempdir");
275 let path = dir.path().join("bookmarks.toml");
276
277 {
278 let mut store = BookmarkStore::open(path.clone()).expect("open");
279 store
280 .add("work".to_owned(), PathBuf::from("/work/project"))
281 .expect("add");
282 }
283
284 let store2 = BookmarkStore::open(path).expect("reopen");
285 assert_eq!(store2.get("work"), Some(Path::new("/work/project")));
286 }
287
288 #[test]
289 fn get_nonexistent_returns_none() {
290 let (_dir, store) = temp_store();
291 assert_eq!(store.get("nope"), None);
292 }
293}