Skip to main content

ccboard_core/
bookmarks.rs

1//! Session bookmark store — persisted to ~/.ccboard/bookmarks.json
2//!
3//! Sessions can be tagged with a short label (e.g. "important", "bug-fix") and an
4//! optional free-text note. The store is a thin wrapper around a HashMap serialised
5//! to JSON via atomic write (tmp → rename).
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use anyhow::{Context, Result};
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13
14/// A single bookmark entry
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16pub struct BookmarkEntry {
17    /// Short label chosen by the user (e.g. "important", "bug", "reference")
18    pub tag: String,
19
20    /// Optional free-text note
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub note: Option<String>,
23
24    /// When the bookmark was created
25    pub created_at: DateTime<Utc>,
26}
27
28/// Persisted bookmark store
29///
30/// Backed by `~/.ccboard/bookmarks.json`.  All mutating methods persist to disk
31/// immediately (atomic write).  Read methods operate on the in-memory map and
32/// are O(1) / O(n).
33#[derive(Debug, Default)]
34pub struct BookmarkStore {
35    path: PathBuf,
36    entries: HashMap<String, BookmarkEntry>, // key = session_id
37}
38
39impl BookmarkStore {
40    /// Load from `path`.  If the file does not exist, an empty store is returned.
41    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    /// Return the filesystem path backing this store
55    pub fn path(&self) -> &Path {
56        &self.path
57    }
58
59    // ── Write operations ────────────────────────────────────────────────────
60
61    /// Add or update a bookmark.  Persists immediately.
62    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    /// Remove a bookmark.  Persists immediately.  Returns `true` if something
80    /// was removed.
81    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    /// Toggle: remove if present, add with `tag` if absent.
90    /// Returns `true` if the session is now bookmarked.
91    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    // ── Read operations (no I/O) ────────────────────────────────────────────
102
103    /// Return `true` if the session has a bookmark
104    pub fn is_bookmarked(&self, session_id: &str) -> bool {
105        self.entries.contains_key(session_id)
106    }
107
108    /// Return the entry for a session, if any
109    pub fn get(&self, session_id: &str) -> Option<&BookmarkEntry> {
110        self.entries.get(session_id)
111    }
112
113    /// All bookmarked session IDs
114    pub fn all_ids(&self) -> impl Iterator<Item = &str> {
115        self.entries.keys().map(|s| s.as_str())
116    }
117
118    /// Number of bookmarks
119    pub fn len(&self) -> usize {
120        self.entries.len()
121    }
122
123    /// Whether the store is empty
124    pub fn is_empty(&self) -> bool {
125        self.entries.is_empty()
126    }
127
128    // ── Persistence ─────────────────────────────────────────────────────────
129
130    fn save(&self) -> Result<()> {
131        // Ensure parent directory exists
132        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        // Atomic write: tmp file → rename
139        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// ── Tests ────────────────────────────────────────────────────────────────────
154
155#[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        // Remove the file so BookmarkStore::load() starts empty
163        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        // Reload from disk
216        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}