Skip to main content

trail/plugin/
bookmarks.rs

1//! Bookmark store: a persisted, named-path registry.
2//!
3//! Bookmarks are stored as a TOML file (`bookmarks.toml`) in the user's
4//! Trail data directory (resolved via the `directories` crate). Each bookmark
5//! maps a short, user-chosen name to an absolute filesystem path.
6//!
7//! The store is loaded once at startup and written atomically on every
8//! mutation. Persistence failures are logged at `debug` level and do not
9//! abort navigation — a bookmark that fails to save is inconvenient, not
10//! catastrophic.
11
12use std::collections::BTreeMap;
13use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16use thiserror::Error;
17
18/// Errors produced while loading or saving the bookmark store.
19#[derive(Debug, Error)]
20pub enum BookmarkError {
21    /// The bookmark store file could not be read.
22    #[error("failed to read bookmarks from {path}: {source}")]
23    Read {
24        /// Store file path.
25        path: PathBuf,
26        /// I/O error.
27        #[source]
28        source: std::io::Error,
29    },
30    /// The bookmark store file could not be written.
31    #[error("failed to write bookmarks to {path}: {source}")]
32    Write {
33        /// Store file path.
34        path: PathBuf,
35        /// I/O error.
36        #[source]
37        source: std::io::Error,
38    },
39    /// A TOML parse error occurred while loading the store.
40    #[error("failed to parse bookmarks TOML: {0}")]
41    Parse(#[from] toml::de::Error),
42    /// A TOML serialization error occurred while saving the store.
43    #[error("failed to serialize bookmarks TOML: {0}")]
44    Serialize(#[from] toml::ser::Error),
45}
46
47/// The on-disk TOML format for the bookmark store.
48#[derive(Debug, Default, Serialize, Deserialize)]
49struct BookmarksFile {
50    /// Map from bookmark name → absolute path string.
51    #[serde(default)]
52    bookmarks: BTreeMap<String, String>,
53}
54
55/// A persisted, named-path bookmark registry.
56///
57/// Loaded from and saved to a `bookmarks.toml` file in the user's Trail
58/// data directory. Mutations are written immediately after every change.
59///
60/// Internally, bookmarks are a `BTreeMap<String, PathBuf>` so listing them
61/// is always in alphabetical order.
62#[derive(Debug)]
63pub struct BookmarkStore {
64    /// Path to the backing TOML file (may not exist yet on a fresh install).
65    file_path: PathBuf,
66    /// In-memory map of name → absolute path.
67    map: BTreeMap<String, PathBuf>,
68}
69
70impl BookmarkStore {
71    /// Opens the bookmark store from `file_path`, creating it if absent.
72    ///
73    /// If the file does not exist, an empty store is returned (not an error).
74    ///
75    /// # Errors
76    ///
77    /// Returns [`BookmarkError::Read`] if the file exists but cannot be read,
78    /// or [`BookmarkError::Parse`] if the TOML is malformed.
79    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    /// Adds or replaces a bookmark named `name` pointing to `path`.
99    ///
100    /// Persists the updated store immediately.
101    ///
102    /// # Errors
103    ///
104    /// Returns a [`BookmarkError`] if serialization or the write fails.
105    pub fn add(&mut self, name: String, path: PathBuf) -> Result<(), BookmarkError> {
106        self.map.insert(name, path);
107        self.save()
108    }
109
110    /// Removes the bookmark named `name`.
111    ///
112    /// Returns `true` if a bookmark with that name existed and was removed.
113    /// Persists the updated store even if no entry was removed (idempotent).
114    ///
115    /// # Errors
116    ///
117    /// Returns a [`BookmarkError`] if serialization or the write fails.
118    // clippy: dead_code — API consumed in Phase 9 UI
119    #[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    /// Returns the absolute path for bookmark `name`, or `None` if it does
127    /// not exist.
128    pub fn get(&self, name: &str) -> Option<&Path> {
129        self.map.get(name).map(|p| p.as_path())
130    }
131
132    /// Returns an iterator over `(name, path)` pairs in alphabetical order.
133    // clippy: dead_code — API consumed in Phase 9 UI
134    #[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    /// Returns `true` if the store contains no bookmarks.
140    // clippy: dead_code — API consumed in Phase 9 UI
141    #[allow(dead_code)]
142    pub fn is_empty(&self) -> bool {
143        self.map.is_empty()
144    }
145
146    /// Returns the number of bookmarks in the store.
147    // clippy: dead_code — API consumed in Phase 9 UI
148    #[allow(dead_code)]
149    pub fn len(&self) -> usize {
150        self.map.len()
151    }
152
153    /// Saves the current in-memory map to `self.file_path`.
154    ///
155    /// Creates parent directories if they do not exist.
156    ///
157    /// # Errors
158    ///
159    /// Returns [`BookmarkError::Write`] on I/O failure or
160    /// [`BookmarkError::Serialize`] on TOML serialization failure.
161    fn save(&self) -> Result<(), BookmarkError> {
162        // Ensure the parent directory exists.
163        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/// Returns the default path for the bookmark store in the user's data directory.
187///
188/// On Linux/macOS this is typically `~/.local/share/trail/bookmarks.toml`;
189/// on Windows `%APPDATA%\trail\bookmarks.toml`. Falls back to a path in
190/// the current directory if the platform data dir is unavailable.
191// clippy: dead_code — API consumed in Phase 9 UI
192#[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}