ccboard_core/
bookmarks.rs1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use anyhow::{Context, Result};
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16pub struct BookmarkEntry {
17 pub tag: String,
19
20 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub note: Option<String>,
23
24 pub created_at: DateTime<Utc>,
26}
27
28#[derive(Debug, Default)]
34pub struct BookmarkStore {
35 path: PathBuf,
36 entries: HashMap<String, BookmarkEntry>, }
38
39impl BookmarkStore {
40 pub fn load(path: impl Into<PathBuf>) -> Result<Self> {
42 let path = path.into();
43 let entries = if path.exists() {
44 let raw = std::fs::read_to_string(&path)
45 .with_context(|| format!("Failed to read {}", path.display()))?;
46 serde_json::from_str::<HashMap<String, BookmarkEntry>>(&raw)
47 .with_context(|| format!("Failed to parse {}", path.display()))?
48 } else {
49 HashMap::new()
50 };
51 Ok(Self { path, entries })
52 }
53
54 pub fn path(&self) -> &Path {
56 &self.path
57 }
58
59 pub fn upsert(
63 &mut self,
64 session_id: &str,
65 tag: impl Into<String>,
66 note: Option<String>,
67 ) -> Result<()> {
68 self.entries.insert(
69 session_id.to_string(),
70 BookmarkEntry {
71 tag: tag.into(),
72 note,
73 created_at: Utc::now(),
74 },
75 );
76 self.save()
77 }
78
79 pub fn remove(&mut self, session_id: &str) -> Result<bool> {
82 let removed = self.entries.remove(session_id).is_some();
83 if removed {
84 self.save()?;
85 }
86 Ok(removed)
87 }
88
89 pub fn toggle(&mut self, session_id: &str, tag: impl Into<String>) -> Result<bool> {
92 if self.entries.contains_key(session_id) {
93 self.remove(session_id)?;
94 Ok(false)
95 } else {
96 self.upsert(session_id, tag, None)?;
97 Ok(true)
98 }
99 }
100
101 pub fn is_bookmarked(&self, session_id: &str) -> bool {
105 self.entries.contains_key(session_id)
106 }
107
108 pub fn get(&self, session_id: &str) -> Option<&BookmarkEntry> {
110 self.entries.get(session_id)
111 }
112
113 pub fn all_ids(&self) -> impl Iterator<Item = &str> {
115 self.entries.keys().map(|s| s.as_str())
116 }
117
118 pub fn len(&self) -> usize {
120 self.entries.len()
121 }
122
123 pub fn is_empty(&self) -> bool {
125 self.entries.is_empty()
126 }
127
128 fn save(&self) -> Result<()> {
131 if let Some(parent) = self.path.parent() {
133 std::fs::create_dir_all(parent)
134 .with_context(|| format!("Failed to create {}", parent.display()))?;
135 }
136 let json =
137 serde_json::to_string_pretty(&self.entries).context("Failed to serialise bookmarks")?;
138 let tmp = self.path.with_extension("json.tmp");
140 std::fs::write(&tmp, &json)
141 .with_context(|| format!("Failed to write {}", tmp.display()))?;
142 std::fs::rename(&tmp, &self.path).with_context(|| {
143 format!(
144 "Failed to rename {} → {}",
145 tmp.display(),
146 self.path.display()
147 )
148 })?;
149 Ok(())
150 }
151}
152
153#[cfg(test)]
156mod tests {
157 use super::*;
158 use tempfile::NamedTempFile;
159
160 fn temp_store() -> (BookmarkStore, NamedTempFile) {
161 let f = NamedTempFile::new().unwrap();
162 let path = f.path().to_path_buf();
164 std::fs::remove_file(&path).ok();
165 let store = BookmarkStore::load(&path).unwrap();
166 (store, f)
167 }
168
169 #[test]
170 fn test_upsert_and_is_bookmarked() {
171 let (mut store, _f) = temp_store();
172 assert!(!store.is_bookmarked("sess-1"));
173
174 store.upsert("sess-1", "important", None).unwrap();
175 assert!(store.is_bookmarked("sess-1"));
176 assert_eq!(store.get("sess-1").unwrap().tag, "important");
177 assert_eq!(store.len(), 1);
178 }
179
180 #[test]
181 fn test_remove() {
182 let (mut store, _f) = temp_store();
183 store.upsert("sess-2", "bug", None).unwrap();
184 let removed = store.remove("sess-2").unwrap();
185 assert!(removed);
186 assert!(!store.is_bookmarked("sess-2"));
187 assert_eq!(store.len(), 0);
188 }
189
190 #[test]
191 fn test_toggle() {
192 let (mut store, _f) = temp_store();
193 let now_bookmarked = store.toggle("sess-3", "ref").unwrap();
194 assert!(now_bookmarked);
195 assert!(store.is_bookmarked("sess-3"));
196
197 let now_bookmarked = store.toggle("sess-3", "ref").unwrap();
198 assert!(!now_bookmarked);
199 assert!(!store.is_bookmarked("sess-3"));
200 }
201
202 #[test]
203 fn test_persist_and_reload() {
204 let f = NamedTempFile::new().unwrap();
205 let path = f.path().to_path_buf();
206 std::fs::remove_file(&path).ok();
207
208 {
209 let mut store = BookmarkStore::load(&path).unwrap();
210 store
211 .upsert("sess-persist", "keep", Some("a note".into()))
212 .unwrap();
213 }
214
215 let store2 = BookmarkStore::load(&path).unwrap();
217 assert!(store2.is_bookmarked("sess-persist"));
218 let entry = store2.get("sess-persist").unwrap();
219 assert_eq!(entry.tag, "keep");
220 assert_eq!(entry.note.as_deref(), Some("a note"));
221 }
222
223 #[test]
224 fn test_empty_store_if_file_missing() {
225 let (store, _f) = temp_store();
226 assert_eq!(store.len(), 0);
227 }
228}