Skip to main content

remendo/
bookmarks.rs

1//! Persistent bookmark storage for patchsets.
2//!
3//! Bookmarks are keyed by `(remote_name, patchset_id)` and persisted
4//! as JSON to `$XDG_STATE_HOME/remendo/bookmarks.json`.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashSet;
8use std::path::Path;
9
10/// A single bookmark entry for serialization.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
12struct BookmarkEntry {
13    remote: String,
14    patchset_id: i64,
15}
16
17/// On-disk bookmark file format.
18#[derive(Debug, Serialize, Deserialize)]
19struct BookmarkFile {
20    version: u32,
21    bookmarks: Vec<BookmarkEntry>,
22}
23
24/// In-memory bookmark store.
25///
26/// Holds the set of bookmarked patchsets and provides load/save
27/// for JSON persistence.
28#[derive(Debug, Clone, Default)]
29pub struct BookmarkStore {
30    bookmarks: HashSet<(String, i64)>,
31}
32
33impl BookmarkStore {
34    /// Create an empty bookmark store.
35    #[must_use]
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    /// Load bookmarks from a JSON file. Returns an empty store on any error.
41    #[must_use]
42    pub fn load(path: &Path) -> Self {
43        let Ok(content) = std::fs::read_to_string(path) else {
44            return Self::new();
45        };
46        let file: BookmarkFile = match serde_json::from_str(&content) {
47            Ok(f) => f,
48            Err(e) => {
49                tracing::warn!(error = %e, "failed to parse bookmarks file, starting empty");
50                return Self::new();
51            }
52        };
53        let bookmarks = file
54            .bookmarks
55            .into_iter()
56            .map(|e| (e.remote, e.patchset_id))
57            .collect();
58        Self { bookmarks }
59    }
60
61    /// Save bookmarks to a JSON file. Uses atomic write (tmp + rename).
62    ///
63    /// # Errors
64    ///
65    /// Returns an error string if the write fails.
66    pub fn save(&self, path: &Path) -> Result<(), String> {
67        use std::io::Write;
68
69        let entries: Vec<BookmarkEntry> = self
70            .bookmarks
71            .iter()
72            .map(|(remote, id)| BookmarkEntry {
73                remote: remote.clone(),
74                patchset_id: *id,
75            })
76            .collect();
77
78        let file = BookmarkFile {
79            version: 1,
80            bookmarks: entries,
81        };
82
83        let json =
84            serde_json::to_string_pretty(&file).map_err(|e| format!("serialize error: {e}"))?;
85
86        // Atomic write: write to .tmp, then rename
87        let tmp_path = path.with_extension("json.tmp");
88        if let Some(parent) = path.parent() {
89            let _ = std::fs::create_dir_all(parent);
90        }
91        let mut f =
92            std::fs::File::create(&tmp_path).map_err(|e| format!("create tmp file: {e}"))?;
93        f.write_all(json.as_bytes())
94            .map_err(|e| format!("write tmp file: {e}"))?;
95        std::fs::rename(&tmp_path, path).map_err(|e| format!("rename tmp to final: {e}"))?;
96        Ok(())
97    }
98
99    /// Toggle a bookmark. Returns `true` if the patchset is now bookmarked.
100    pub fn toggle(&mut self, remote: &str, patchset_id: i64) -> bool {
101        let key = (remote.to_string(), patchset_id);
102        if self.bookmarks.contains(&key) {
103            self.bookmarks.remove(&key);
104            false
105        } else {
106            self.bookmarks.insert(key);
107            true
108        }
109    }
110
111    /// Number of bookmarked patchsets.
112    #[must_use]
113    pub fn len(&self) -> usize {
114        self.bookmarks.len()
115    }
116
117    /// Whether the bookmark store is empty.
118    #[must_use]
119    pub fn is_empty(&self) -> bool {
120        self.bookmarks.is_empty()
121    }
122
123    /// Check if a patchset is bookmarked.
124    #[must_use]
125    pub fn contains(&self, remote: &str, patchset_id: i64) -> bool {
126        self.bookmarks.contains(&(remote.to_string(), patchset_id))
127    }
128}
129
130#[cfg(test)]
131#[allow(clippy::expect_used)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn toggle_adds_and_removes() {
137        let mut store = BookmarkStore::new();
138        assert!(store.toggle("upstream", 42));
139        assert!(store.contains("upstream", 42));
140        assert!(!store.toggle("upstream", 42));
141        assert!(!store.contains("upstream", 42));
142    }
143
144    #[test]
145    fn different_remotes_are_independent() {
146        let mut store = BookmarkStore::new();
147        store.toggle("upstream", 42);
148        assert!(!store.contains("staging", 42));
149    }
150
151    #[test]
152    fn load_missing_file_returns_empty() {
153        let store = BookmarkStore::load(Path::new("/nonexistent/bookmarks.json"));
154        assert!(!store.contains("any", 1));
155    }
156
157    #[test]
158    fn save_and_load_roundtrip() {
159        let dir = tempfile::tempdir().expect("create temp dir");
160        let path = dir.path().join("bookmarks.json");
161
162        let mut store = BookmarkStore::new();
163        store.toggle("upstream", 100);
164        store.toggle("staging", 200);
165        store.save(&path).expect("save");
166
167        let loaded = BookmarkStore::load(&path);
168        assert!(loaded.contains("upstream", 100));
169        assert!(loaded.contains("staging", 200));
170        assert!(!loaded.contains("upstream", 200));
171    }
172}