1use serde::{Deserialize, Serialize};
7use std::collections::HashSet;
8use std::path::Path;
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
12struct BookmarkEntry {
13 remote: String,
14 patchset_id: i64,
15}
16
17#[derive(Debug, Serialize, Deserialize)]
19struct BookmarkFile {
20 version: u32,
21 bookmarks: Vec<BookmarkEntry>,
22}
23
24#[derive(Debug, Clone, Default)]
29pub struct BookmarkStore {
30 bookmarks: HashSet<(String, i64)>,
31}
32
33impl BookmarkStore {
34 #[must_use]
36 pub fn new() -> Self {
37 Self::default()
38 }
39
40 #[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 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 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 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 #[must_use]
113 pub fn len(&self) -> usize {
114 self.bookmarks.len()
115 }
116
117 #[must_use]
119 pub fn is_empty(&self) -> bool {
120 self.bookmarks.is_empty()
121 }
122
123 #[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}