Skip to main content

brows3r_lib/
bookmarks.rs

1//! Bookmarks and recent locations for the sidebar.
2//!
3//! # Bookmarks
4//!
5//! `BookmarkStore` persists to `${app_config_dir}/bookmarks.json`.  Writes are
6//! atomic (write-to-tmp, rename) so a crash cannot corrupt the file.
7//!
8//! # Recents
9//!
10//! `RecentsStore` is an in-memory ring buffer (cap 50) that de-duplicates
11//! consecutive identical locations.  The snapshot is written to
12//! `${app_config_dir}/recents.json` on explicit flush (called from commands).
13//!
14//! # OCP
15//!
16//! - `Bookmark` and `RecentLocation` are open for new optional fields (tags,
17//!   color, …) via serde `skip_serializing_if`.
18//! - The validation gate is not in this module — it lives in the command layer
19//!   (`bookmarks_cmd.rs`) so the store stays transport-agnostic.
20
21use std::{
22    collections::VecDeque,
23    path::{Path, PathBuf},
24    sync::Arc,
25};
26
27use serde::{Deserialize, Serialize};
28use tokio::sync::RwLock;
29use uuid::Uuid;
30
31use crate::{
32    error::AppError,
33    ids::{BucketId, ProfileId},
34};
35
36// ---------------------------------------------------------------------------
37// Bookmark
38// ---------------------------------------------------------------------------
39
40/// A persisted sidebar bookmark.
41///
42/// `label` is optional so callers can store bookmarks without naming them;
43/// the UI falls back to the `prefix` as the display string.
44///
45/// Additional fields (tags, color, …) may be added as `Option` fields
46/// without a breaking schema change.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(rename_all = "camelCase")]
49pub struct Bookmark {
50    /// UUID v4 minted at creation time.
51    pub id: String,
52    /// The AWS credential profile this bookmark belongs to.
53    pub profile_id: ProfileId,
54    /// The S3 bucket.
55    pub bucket: BucketId,
56    /// The S3 prefix (empty string = bucket root).
57    pub prefix: String,
58    /// Human-readable label.  `None` means use `prefix` as the display name.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub label: Option<String>,
61    /// Unix epoch in milliseconds at the time the bookmark was created.
62    pub created_at: i64,
63}
64
65/// Patch accepted by `BookmarkStore::update`.
66///
67/// All fields are optional — passing `None` for a field leaves it unchanged.
68#[derive(Debug, Clone, Serialize, Deserialize, Default)]
69#[serde(rename_all = "camelCase")]
70pub struct BookmarkPatch {
71    pub label: Option<String>,
72}
73
74/// Input for `BookmarkStore::add`.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct BookmarkInput {
78    pub profile_id: ProfileId,
79    pub bucket: BucketId,
80    pub prefix: String,
81    pub label: Option<String>,
82}
83
84// ---------------------------------------------------------------------------
85// BookmarkStore
86// ---------------------------------------------------------------------------
87
88/// Persisted list of bookmarks.
89///
90/// All mutations call `save_to` synchronously (blocking I/O) because they
91/// happen inside a `RwLock` write guard already held by the command.  The
92/// bookmarks file is small (KBs at most), so blocking is acceptable.
93#[derive(Debug, Default, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase")]
95pub struct BookmarkStore {
96    bookmarks: Vec<Bookmark>,
97}
98
99impl BookmarkStore {
100    /// Load from `path`.  Returns an empty store if the file does not exist.
101    pub fn load(path: &Path) -> Result<Self, AppError> {
102        if !path.exists() {
103            return Ok(Self::default());
104        }
105        let raw = std::fs::read_to_string(path).map_err(|e| AppError::Internal {
106            trace_id: format!("bookmarks_load_read: {e}"),
107        })?;
108        serde_json::from_str(&raw).map_err(|e| AppError::Internal {
109            trace_id: format!("bookmarks_load_parse: {e}"),
110        })
111    }
112
113    /// Persist to `path` atomically.
114    fn save_to(&self, path: &Path) -> Result<(), AppError> {
115        let json = serde_json::to_string_pretty(self).map_err(|e| AppError::Internal {
116            trace_id: format!("bookmarks_save_serialize: {e}"),
117        })?;
118        if let Some(parent) = path.parent() {
119            std::fs::create_dir_all(parent).map_err(|e| AppError::Internal {
120                trace_id: format!("bookmarks_save_mkdir: {e}"),
121            })?;
122        }
123        let tmp = path.with_extension("json.tmp");
124        std::fs::write(&tmp, json.as_bytes()).map_err(|e| AppError::Internal {
125            trace_id: format!("bookmarks_save_write: {e}"),
126        })?;
127        std::fs::rename(&tmp, path).map_err(|e| AppError::Internal {
128            trace_id: format!("bookmarks_save_rename: {e}"),
129        })?;
130        Ok(())
131    }
132
133    /// Return all bookmarks in insertion order.
134    pub fn list(&self) -> Vec<Bookmark> {
135        self.bookmarks.clone()
136    }
137
138    /// Add a new bookmark and persist.  Returns the created `Bookmark`.
139    pub fn add(&mut self, input: BookmarkInput, path: &Path) -> Result<Bookmark, AppError> {
140        let bookmark = Bookmark {
141            id: Uuid::new_v4().to_string(),
142            profile_id: input.profile_id,
143            bucket: input.bucket,
144            prefix: input.prefix,
145            label: input.label,
146            created_at: unix_ms_now(),
147        };
148        self.bookmarks.push(bookmark.clone());
149        self.save_to(path)?;
150        Ok(bookmark)
151    }
152
153    /// Remove a bookmark by id.  Returns `true` if found and removed.
154    pub fn remove(&mut self, id: &str, path: &Path) -> Result<bool, AppError> {
155        let before = self.bookmarks.len();
156        self.bookmarks.retain(|b| b.id != id);
157        let removed = self.bookmarks.len() < before;
158        if removed {
159            self.save_to(path)?;
160        }
161        Ok(removed)
162    }
163
164    /// Update a bookmark's mutable fields.  Returns the updated `Bookmark` or
165    /// `NotFound` when no bookmark with `id` exists.
166    pub fn update(
167        &mut self,
168        id: &str,
169        patch: BookmarkPatch,
170        path: &Path,
171    ) -> Result<Bookmark, AppError> {
172        let bm = self
173            .bookmarks
174            .iter_mut()
175            .find(|b| b.id == id)
176            .ok_or_else(|| AppError::NotFound {
177                resource: format!("bookmark:{id}"),
178            })?;
179
180        if let Some(label) = patch.label {
181            bm.label = Some(label);
182        }
183
184        let result = bm.clone();
185        self.save_to(path)?;
186        Ok(result)
187    }
188}
189
190/// Shared handle for `BookmarkStore`.
191pub type BookmarkStoreHandle = Arc<RwLock<BookmarkStoreState>>;
192
193/// Wrapper that bundles the store with its backing file path.
194pub struct BookmarkStoreState {
195    pub store: BookmarkStore,
196    pub path: PathBuf,
197}
198
199impl BookmarkStoreState {
200    pub fn new(store: BookmarkStore, path: PathBuf) -> Self {
201        Self { store, path }
202    }
203}
204
205// ---------------------------------------------------------------------------
206// RecentLocation
207// ---------------------------------------------------------------------------
208
209/// One visited S3 location recorded for the recents list.
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211#[serde(rename_all = "camelCase")]
212pub struct RecentLocation {
213    pub profile_id: ProfileId,
214    pub bucket: BucketId,
215    pub prefix: String,
216    /// Unix epoch milliseconds of the visit.
217    pub visited_at: i64,
218}
219
220// ---------------------------------------------------------------------------
221// RecentsStore
222// ---------------------------------------------------------------------------
223
224/// Ring buffer (cap 50) of recent locations.
225///
226/// De-duplicates consecutive identical `(profile_id, bucket, prefix)` triples
227/// so rapid re-navigation to the same folder does not pollute the list.
228///
229/// Persisted as `${app_config_dir}/recents.json`.
230pub struct RecentsStore {
231    buffer: VecDeque<RecentLocation>,
232    /// Maximum number of entries.  Kept as a field so tests can override.
233    cap: usize,
234    pub path: PathBuf,
235}
236
237impl RecentsStore {
238    const DEFAULT_CAP: usize = 50;
239
240    pub fn new(path: PathBuf) -> Self {
241        Self {
242            buffer: VecDeque::new(),
243            cap: Self::DEFAULT_CAP,
244            path,
245        }
246    }
247
248    /// Load from `path`.  Returns an empty store if the file does not exist.
249    pub fn load(path: PathBuf) -> Self {
250        let buffer = if path.exists() {
251            std::fs::read_to_string(&path)
252                .ok()
253                .and_then(|raw| serde_json::from_str::<Vec<RecentLocation>>(&raw).ok())
254                .map(|v| v.into_iter().collect::<VecDeque<_>>())
255                .unwrap_or_default()
256        } else {
257            VecDeque::new()
258        };
259        // Clamp to cap in case the file was written by a future version with a
260        // larger cap.
261        let mut store = Self {
262            buffer,
263            cap: Self::DEFAULT_CAP,
264            path,
265        };
266        store.clamp();
267        store
268    }
269
270    fn clamp(&mut self) {
271        while self.buffer.len() > self.cap {
272            self.buffer.pop_back();
273        }
274    }
275
276    /// Record a navigation.  De-duplicates consecutive identical locations
277    /// (same profile, bucket, prefix).  Evicts the oldest entry when at cap.
278    pub fn track(&mut self, profile_id: ProfileId, bucket: BucketId, prefix: String) {
279        // De-duplicate: if the front is identical, just bump its timestamp.
280        if let Some(front) = self.buffer.front_mut() {
281            if front.profile_id == profile_id && front.bucket == bucket && front.prefix == prefix {
282                front.visited_at = unix_ms_now();
283                return;
284            }
285        }
286
287        // Also remove any older entry for the same location so it surfaces at
288        // the top without creating a duplicate.
289        self.buffer
290            .retain(|r| !(r.profile_id == profile_id && r.bucket == bucket && r.prefix == prefix));
291
292        if self.buffer.len() >= self.cap {
293            self.buffer.pop_back();
294        }
295        self.buffer.push_front(RecentLocation {
296            profile_id,
297            bucket,
298            prefix,
299            visited_at: unix_ms_now(),
300        });
301    }
302
303    /// Return all recent locations (newest first).
304    pub fn list(&self) -> Vec<RecentLocation> {
305        self.buffer.iter().cloned().collect()
306    }
307
308    /// Clear all entries.
309    pub fn clear(&mut self) {
310        self.buffer.clear();
311    }
312
313    /// Flush to disk atomically (tmp + rename).
314    ///
315    /// Returns `AppError::Internal` on serialization, mkdir, write, or rename
316    /// failure so callers (e.g. `recents_clear`) can surface the failure to
317    /// the user. Previously this used `let _ = ...` everywhere and silently
318    /// dropped failures, which meant a "Clear recents" click could leave the
319    /// stale on-disk list intact and reappear on the next launch.
320    pub fn flush(&self) -> Result<(), AppError> {
321        let entries: Vec<&RecentLocation> = self.buffer.iter().collect();
322        let json = serde_json::to_string_pretty(&entries).map_err(|e| AppError::Internal {
323            trace_id: format!("recents_flush_serialize: {e}"),
324        })?;
325        if let Some(parent) = self.path.parent() {
326            std::fs::create_dir_all(parent).map_err(|e| AppError::Internal {
327                trace_id: format!("recents_flush_mkdir: {e}"),
328            })?;
329        }
330        let tmp = self.path.with_extension("json.tmp");
331        std::fs::write(&tmp, json.as_bytes()).map_err(|e| AppError::Internal {
332            trace_id: format!("recents_flush_write: {e}"),
333        })?;
334        std::fs::rename(&tmp, &self.path).map_err(|e| AppError::Internal {
335            trace_id: format!("recents_flush_rename: {e}"),
336        })?;
337        Ok(())
338    }
339}
340
341/// Shared handle for `RecentsStore`.
342pub type RecentsHandle = Arc<RwLock<RecentsStore>>;
343
344// ---------------------------------------------------------------------------
345// Helpers
346// ---------------------------------------------------------------------------
347
348fn unix_ms_now() -> i64 {
349    std::time::SystemTime::now()
350        .duration_since(std::time::UNIX_EPOCH)
351        .map(|d| d.as_millis() as i64)
352        .unwrap_or(0)
353}
354
355// ---------------------------------------------------------------------------
356// Tests
357// ---------------------------------------------------------------------------
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use tempfile::tempdir;
363
364    fn pid(s: &str) -> ProfileId {
365        ProfileId::new(s)
366    }
367    fn bid(s: &str) -> BucketId {
368        BucketId::new(s)
369    }
370
371    // -----------------------------------------------------------------------
372    // BookmarkStore
373    // -----------------------------------------------------------------------
374
375    #[test]
376    fn bookmark_add_and_list() {
377        let dir = tempdir().unwrap();
378        let path = dir.path().join("bookmarks.json");
379        let mut store = BookmarkStore::load(&path).unwrap();
380
381        let bm = store
382            .add(
383                BookmarkInput {
384                    profile_id: pid("p1"),
385                    bucket: bid("bucket-a"),
386                    prefix: "folder/".to_string(),
387                    label: Some("My folder".to_string()),
388                },
389                &path,
390            )
391            .unwrap();
392
393        assert!(!bm.id.is_empty());
394        assert_eq!(bm.profile_id.as_str(), "p1");
395        assert_eq!(bm.bucket.as_str(), "bucket-a");
396        assert_eq!(bm.prefix, "folder/");
397        assert_eq!(bm.label.as_deref(), Some("My folder"));
398
399        let list = store.list();
400        assert_eq!(list.len(), 1);
401        assert_eq!(list[0].id, bm.id);
402    }
403
404    #[test]
405    fn bookmark_remove_returns_true_on_success() {
406        let dir = tempdir().unwrap();
407        let path = dir.path().join("bookmarks.json");
408        let mut store = BookmarkStore::load(&path).unwrap();
409
410        let bm = store
411            .add(
412                BookmarkInput {
413                    profile_id: pid("p1"),
414                    bucket: bid("b"),
415                    prefix: "".to_string(),
416                    label: None,
417                },
418                &path,
419            )
420            .unwrap();
421
422        let removed = store.remove(&bm.id, &path).unwrap();
423        assert!(removed);
424        assert!(store.list().is_empty());
425    }
426
427    #[test]
428    fn bookmark_remove_missing_id_returns_false() {
429        let dir = tempdir().unwrap();
430        let path = dir.path().join("bookmarks.json");
431        let mut store = BookmarkStore::load(&path).unwrap();
432        let removed = store.remove("no-such-id", &path).unwrap();
433        assert!(!removed);
434    }
435
436    #[test]
437    fn bookmark_update_label() {
438        let dir = tempdir().unwrap();
439        let path = dir.path().join("bookmarks.json");
440        let mut store = BookmarkStore::load(&path).unwrap();
441
442        let bm = store
443            .add(
444                BookmarkInput {
445                    profile_id: pid("p1"),
446                    bucket: bid("b"),
447                    prefix: "".to_string(),
448                    label: Some("old".to_string()),
449                },
450                &path,
451            )
452            .unwrap();
453
454        let updated = store
455            .update(
456                &bm.id,
457                BookmarkPatch {
458                    label: Some("new label".to_string()),
459                },
460                &path,
461            )
462            .unwrap();
463
464        assert_eq!(updated.label.as_deref(), Some("new label"));
465        // Verify list reflects the change.
466        let list = store.list();
467        assert_eq!(list[0].label.as_deref(), Some("new label"));
468    }
469
470    #[test]
471    fn bookmark_update_missing_returns_not_found() {
472        let dir = tempdir().unwrap();
473        let path = dir.path().join("bookmarks.json");
474        let mut store = BookmarkStore::load(&path).unwrap();
475        let err = store
476            .update("bad-id", BookmarkPatch::default(), &path)
477            .unwrap_err();
478        assert!(matches!(err, AppError::NotFound { .. }));
479    }
480
481    #[test]
482    fn bookmark_persistence_round_trip() {
483        let dir = tempdir().unwrap();
484        let path = dir.path().join("bookmarks.json");
485        {
486            let mut store = BookmarkStore::load(&path).unwrap();
487            store
488                .add(
489                    BookmarkInput {
490                        profile_id: pid("p1"),
491                        bucket: bid("b"),
492                        prefix: "x/".to_string(),
493                        label: Some("X".to_string()),
494                    },
495                    &path,
496                )
497                .unwrap();
498        }
499        // Re-load from disk.
500        let store2 = BookmarkStore::load(&path).unwrap();
501        let list = store2.list();
502        assert_eq!(list.len(), 1);
503        assert_eq!(list[0].prefix, "x/");
504        assert_eq!(list[0].label.as_deref(), Some("X"));
505    }
506
507    // -----------------------------------------------------------------------
508    // RecentsStore
509    // -----------------------------------------------------------------------
510
511    #[test]
512    fn recents_track_and_list() {
513        let dir = tempdir().unwrap();
514        let path = dir.path().join("recents.json");
515        let mut store = RecentsStore::new(path);
516
517        store.track(pid("p1"), bid("b"), "a/".to_string());
518        store.track(pid("p1"), bid("b"), "b/".to_string());
519
520        let list = store.list();
521        assert_eq!(list.len(), 2);
522        // Newest first.
523        assert_eq!(list[0].prefix, "b/");
524        assert_eq!(list[1].prefix, "a/");
525    }
526
527    #[test]
528    fn recents_dedup_consecutive_identical() {
529        let dir = tempdir().unwrap();
530        let path = dir.path().join("recents.json");
531        let mut store = RecentsStore::new(path);
532
533        store.track(pid("p1"), bid("b"), "x/".to_string());
534        store.track(pid("p1"), bid("b"), "x/".to_string());
535        store.track(pid("p1"), bid("b"), "x/".to_string());
536
537        let list = store.list();
538        assert_eq!(list.len(), 1, "consecutive identical locations must dedup");
539    }
540
541    #[test]
542    fn recents_ring_buffer_eviction_at_cap() {
543        let dir = tempdir().unwrap();
544        let path = dir.path().join("recents.json");
545        let mut store = RecentsStore {
546            buffer: VecDeque::new(),
547            cap: 3,
548            path,
549        };
550
551        for i in 0..5_u32 {
552            store.track(pid("p"), bid("b"), format!("{i}/"));
553        }
554
555        let list = store.list();
556        assert_eq!(list.len(), 3, "must not exceed cap");
557        // The newest three (4/, 3/, 2/) should be retained.
558        assert_eq!(list[0].prefix, "4/");
559        assert_eq!(list[1].prefix, "3/");
560        assert_eq!(list[2].prefix, "2/");
561    }
562
563    #[test]
564    fn recents_clear_empties_list() {
565        let dir = tempdir().unwrap();
566        let path = dir.path().join("recents.json");
567        let mut store = RecentsStore::new(path);
568        store.track(pid("p"), bid("b"), "x/".to_string());
569        store.clear();
570        assert!(store.list().is_empty());
571    }
572
573    #[test]
574    fn recents_persistence_round_trip() {
575        let dir = tempdir().unwrap();
576        let path = dir.path().join("recents.json");
577        {
578            let mut store = RecentsStore::new(path.clone());
579            store.track(pid("p1"), bid("b"), "persisted/".to_string());
580            store.flush().expect("flush must succeed in tmpdir");
581        }
582        let store2 = RecentsStore::load(path);
583        let list = store2.list();
584        assert_eq!(list.len(), 1);
585        assert_eq!(list[0].prefix, "persisted/");
586    }
587}