1use anyhow::{Context, Result};
7use frankensqlite::Connection;
8use frankensqlite::compat::{ConnectionExt, OptionalExtension, RowExt, TransactionExt};
9use frankensqlite::params;
10use serde::{Deserialize, Serialize};
11use std::path::{Path, PathBuf};
12use std::time::{SystemTime, UNIX_EPOCH};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Bookmark {
17 pub id: i64,
19 pub title: String,
21 pub source_path: String,
23 pub line_number: Option<usize>,
25 pub agent: String,
27 pub workspace: String,
29 pub note: String,
31 pub tags: String,
33 pub created_at: i64,
35 pub updated_at: i64,
37 pub snippet: String,
39}
40
41impl Bookmark {
42 pub fn new(
44 title: impl Into<String>,
45 source_path: impl Into<String>,
46 agent: impl Into<String>,
47 workspace: impl Into<String>,
48 ) -> Self {
49 let now = current_timestamp();
50
51 Self {
52 id: 0, title: title.into(),
54 source_path: source_path.into(),
55 line_number: None,
56 agent: agent.into(),
57 workspace: workspace.into(),
58 note: String::new(),
59 tags: String::new(),
60 created_at: now,
61 updated_at: now,
62 snippet: String::new(),
63 }
64 }
65
66 pub fn with_note(mut self, note: impl Into<String>) -> Self {
68 self.note = note.into();
69 self
70 }
71
72 pub fn with_tags(mut self, tags: impl Into<String>) -> Self {
74 self.tags = tags.into();
75 self
76 }
77
78 pub fn with_line(mut self, line: usize) -> Self {
80 self.line_number = Some(line);
81 self
82 }
83
84 pub fn with_snippet(mut self, snippet: impl Into<String>) -> Self {
86 self.snippet = snippet.into();
87 self
88 }
89
90 pub fn tag_list(&self) -> Vec<&str> {
92 self.tags
93 .split(',')
94 .map(str::trim)
95 .filter(|s| !s.is_empty())
96 .collect()
97 }
98
99 pub fn has_tag(&self, tag: &str) -> bool {
101 self.tag_list().iter().any(|t| t.eq_ignore_ascii_case(tag))
102 }
103}
104
105pub struct BookmarkStore {
107 conn: Connection,
108}
109
110impl BookmarkStore {
111 pub fn open(path: &Path) -> Result<Self> {
113 if let Some(parent) = path.parent() {
114 std::fs::create_dir_all(parent)
115 .with_context(|| format!("creating bookmarks directory {}", parent.display()))?;
116 }
117
118 let conn = Connection::open(path.to_string_lossy().as_ref())
119 .with_context(|| format!("opening bookmarks db at {}", path.display()))?;
120
121 conn.execute_batch(
123 "PRAGMA journal_mode = WAL;
124 PRAGMA synchronous = NORMAL;
125 PRAGMA busy_timeout = 5000;
126 PRAGMA foreign_keys = ON;",
127 )?;
128
129 conn.execute_batch(SCHEMA)?;
131
132 Ok(Self { conn })
133 }
134
135 pub fn open_default() -> Result<Self> {
137 let path = default_bookmarks_path();
138 Self::open(&path)
139 }
140
141 pub fn add(&self, bookmark: &Bookmark) -> Result<i64> {
143 let line_number = line_number_to_db(bookmark.line_number)?;
144
145 self.conn.execute_compat(
146 "INSERT INTO bookmarks (title, source_path, line_number, agent, workspace, note, tags, created_at, updated_at, snippet)
147 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
148 params![
149 bookmark.title.as_str(),
150 bookmark.source_path.as_str(),
151 line_number,
152 bookmark.agent.as_str(),
153 bookmark.workspace.as_str(),
154 bookmark.note.as_str(),
155 bookmark.tags.as_str(),
156 bookmark.created_at,
157 bookmark.updated_at,
158 bookmark.snippet.as_str(),
159 ],
160 )?;
161
162 let rowid = self.conn.last_insert_rowid();
163 Ok(rowid)
164 }
165
166 pub fn update(&self, bookmark: &Bookmark) -> Result<bool> {
168 let now = current_timestamp();
169
170 let rows = self.conn.execute_compat(
171 "UPDATE bookmarks SET title = ?1, note = ?2, tags = ?3, updated_at = ?4 WHERE id = ?5",
172 params![
173 bookmark.title.as_str(),
174 bookmark.note.as_str(),
175 bookmark.tags.as_str(),
176 now,
177 bookmark.id
178 ],
179 )?;
180
181 Ok(rows > 0)
182 }
183
184 pub fn remove(&self, id: i64) -> Result<bool> {
186 let rows = self
187 .conn
188 .execute_compat("DELETE FROM bookmarks WHERE id = ?1", params![id])?;
189 Ok(rows > 0)
190 }
191
192 pub fn get(&self, id: i64) -> Result<Option<Bookmark>> {
194 self.conn
195 .query_row_map(
196 "SELECT id, title, source_path, line_number, agent, workspace, note, tags, created_at, updated_at, snippet
197 FROM bookmarks WHERE id = ?1",
198 params![id],
199 row_to_bookmark,
200 )
201 .optional()
202 .context("querying bookmark by id")
203 }
204
205 pub fn list(&self, tag_filter: Option<&str>) -> Result<Vec<Bookmark>> {
207 let sql = "SELECT id, title, source_path, line_number, agent, workspace, note, tags, created_at, updated_at, snippet
208 FROM bookmarks ORDER BY created_at DESC";
209
210 let all_bookmarks: Vec<Bookmark> =
211 self.conn.query_map_collect(sql, &[], row_to_bookmark)?;
212
213 if let Some(tag) = tag_filter {
214 Ok(all_bookmarks
215 .into_iter()
216 .filter(|b| b.has_tag(tag))
217 .collect())
218 } else {
219 Ok(all_bookmarks)
220 }
221 }
222
223 pub fn search(&self, query: &str) -> Result<Vec<Bookmark>> {
225 let escaped = query
227 .to_lowercase()
228 .replace('\\', "\\\\")
229 .replace('%', "\\%")
230 .replace('_', "\\_");
231 let pattern = format!("%{escaped}%");
232
233 let results = self.conn.query_map_collect(
234 "SELECT id, title, source_path, line_number, agent, workspace, note, tags, created_at, updated_at, snippet
235 FROM bookmarks
236 WHERE LOWER(title) LIKE ?1 ESCAPE '\\' OR LOWER(note) LIKE ?1 ESCAPE '\\' OR LOWER(snippet) LIKE ?1 ESCAPE '\\'
237 ORDER BY created_at DESC",
238 params![pattern],
239 row_to_bookmark,
240 ).context("searching bookmarks")?;
241 Ok(results)
242 }
243
244 pub fn all_tags(&self) -> Result<Vec<String>> {
246 let bookmarks = self.list(None)?;
247 let mut tags: Vec<String> = bookmarks
248 .iter()
249 .flat_map(|b| b.tag_list())
250 .map(std::string::ToString::to_string)
251 .collect();
252
253 tags.sort();
254 tags.dedup();
255 Ok(tags)
256 }
257
258 pub fn count(&self) -> Result<usize> {
260 let count: i64 = self.conn.query_row_map(
261 "SELECT COUNT(*) FROM bookmarks",
262 &[],
263 |row: &frankensqlite::Row| row.get_typed(0),
264 )?;
265 usize::try_from(count).context("bookmark count is out of range")
266 }
267
268 pub fn is_bookmarked(&self, source_path: &str, line_number: Option<usize>) -> Result<bool> {
270 let line_number = line_number_to_db(line_number)?;
271 let exists: i64 = self.conn.query_row_map(
272 "SELECT EXISTS(SELECT 1 FROM bookmarks WHERE source_path = ?1 AND line_number IS ?2)",
273 params![source_path, line_number],
274 |row: &frankensqlite::Row| row.get_typed(0),
275 )?;
276 Ok(exists != 0)
277 }
278
279 pub fn export_json(&self) -> Result<String> {
281 let bookmarks = self.list(None)?;
282 serde_json::to_string_pretty(&bookmarks).context("serializing bookmarks to JSON")
283 }
284
285 pub fn import_json(&self, json: &str) -> Result<usize> {
287 let bookmarks: Vec<Bookmark> =
288 serde_json::from_str(json).context("parsing bookmark JSON")?;
289 let mut imported = 0;
290
291 let mut tx = self.conn.transaction()?;
292
293 for mut bookmark in bookmarks {
294 let line_number = line_number_to_db(bookmark.line_number)?;
295
296 let check_params = params![bookmark.source_path.as_str(), line_number];
298 let check_values = frankensqlite::compat::param_slice_to_values(check_params);
299 let exists_row = tx.query_with_params(
300 "SELECT EXISTS(SELECT 1 FROM bookmarks WHERE source_path = ?1 AND line_number IS ?2)",
301 &check_values,
302 )?;
303 let exists: i64 = exists_row
304 .first()
305 .and_then(|row| row.get_typed(0).ok())
306 .unwrap_or(0);
307
308 if exists == 0 {
309 bookmark.id = 0; tx.execute_compat(
311 "INSERT INTO bookmarks (title, source_path, line_number, agent, workspace, note, tags, created_at, updated_at, snippet)
312 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
313 params![
314 bookmark.title.as_str(),
315 bookmark.source_path.as_str(),
316 line_number,
317 bookmark.agent.as_str(),
318 bookmark.workspace.as_str(),
319 bookmark.note.as_str(),
320 bookmark.tags.as_str(),
321 bookmark.created_at,
322 bookmark.updated_at,
323 bookmark.snippet.as_str(),
324 ],
325 )?;
326 imported += 1;
327 }
328 }
329
330 tx.commit()?;
331
332 Ok(imported)
333 }
334}
335
336fn row_to_bookmark(row: &frankensqlite::Row) -> Result<Bookmark, frankensqlite::FrankenError> {
338 Ok(Bookmark {
339 id: row.get_typed(0)?,
340 title: row.get_typed(1)?,
341 source_path: row.get_typed(2)?,
342 line_number: line_number_from_db(row.get_typed::<Option<i64>>(3)?),
343 agent: row.get_typed(4)?,
344 workspace: row.get_typed(5)?,
345 note: row.get_typed(6)?,
346 tags: row.get_typed(7)?,
347 created_at: row.get_typed(8)?,
348 updated_at: row.get_typed(9)?,
349 snippet: row.get_typed(10)?,
350 })
351}
352
353pub fn default_bookmarks_path() -> PathBuf {
355 crate::default_data_dir().join("bookmarks.db")
356}
357
358const SCHEMA: &str = r"
360CREATE TABLE IF NOT EXISTS bookmarks (
361 id INTEGER PRIMARY KEY,
362 title TEXT NOT NULL,
363 source_path TEXT NOT NULL,
364 line_number INTEGER,
365 agent TEXT NOT NULL,
366 workspace TEXT NOT NULL,
367 note TEXT DEFAULT '',
368 tags TEXT DEFAULT '',
369 created_at INTEGER NOT NULL,
370 updated_at INTEGER NOT NULL,
371 snippet TEXT DEFAULT ''
372);
373
374CREATE INDEX IF NOT EXISTS idx_bookmarks_source ON bookmarks(source_path, line_number);
375CREATE INDEX IF NOT EXISTS idx_bookmarks_created ON bookmarks(created_at DESC);
376CREATE INDEX IF NOT EXISTS idx_bookmarks_agent ON bookmarks(agent);
377";
378
379fn line_number_to_db(line_number: Option<usize>) -> Result<Option<i64>> {
380 line_number
381 .map(|n| i64::try_from(n).context("line number exceeds i64 range"))
382 .transpose()
383}
384
385fn line_number_from_db(line_number: Option<i64>) -> Option<usize> {
386 line_number.and_then(|n| usize::try_from(n).ok())
387}
388
389fn current_timestamp() -> i64 {
390 i64::try_from(
391 SystemTime::now()
392 .duration_since(UNIX_EPOCH)
393 .unwrap_or_default()
394 .as_millis(),
395 )
396 .unwrap_or(i64::MAX)
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use tempfile::tempdir;
403
404 fn test_store() -> (BookmarkStore, tempfile::TempDir) {
405 let dir = tempdir().unwrap();
406 let path = dir.path().join("test_bookmarks.db");
407 let store = BookmarkStore::open(&path).unwrap();
408 (store, dir)
409 }
410
411 fn assert_single_search_path(store: &BookmarkStore, query: &str, expected_path: &str) {
412 let results = store.search(query).unwrap();
413 let paths = results
414 .iter()
415 .map(|bookmark| bookmark.source_path.as_str())
416 .collect::<Vec<_>>();
417
418 assert_eq!(
419 paths,
420 vec![expected_path],
421 "query {query:?} should match exactly one source path"
422 );
423 }
424
425 #[test]
426 fn test_create_bookmark() {
427 let bookmark = Bookmark::new("Test", "/path/file.rs", "claude_code", "/workspace")
428 .with_note("Important finding")
429 .with_tags("rust, important")
430 .with_line(42);
431
432 assert_eq!(bookmark.title, "Test");
433 assert_eq!(bookmark.line_number, Some(42));
434 assert!(bookmark.has_tag("rust"));
435 assert!(bookmark.has_tag("important"));
436 assert!(!bookmark.has_tag("python"));
437 }
438
439 #[test]
440 fn test_add_and_get() {
441 let (store, _dir) = test_store();
442 let bookmark = Bookmark::new("Test Result", "/path/to/file.jsonl", "codex", "/my/project")
443 .with_note("Found the bug here");
444
445 let id = store.add(&bookmark).unwrap();
446 assert!(id > 0);
447
448 let retrieved = store.get(id).unwrap().unwrap();
449 assert_eq!(retrieved.title, "Test Result");
450 assert_eq!(retrieved.note, "Found the bug here");
451 }
452
453 #[test]
454 fn test_list_and_count() {
455 let (store, _dir) = test_store();
456
457 store
458 .add(&Bookmark::new("First", "/a.rs", "claude", "/ws"))
459 .unwrap();
460 store
461 .add(&Bookmark::new("Second", "/b.rs", "codex", "/ws"))
462 .unwrap();
463 store
464 .add(&Bookmark::new("Third", "/c.rs", "claude", "/ws"))
465 .unwrap();
466
467 assert_eq!(store.count().unwrap(), 3);
468 assert_eq!(store.list(None).unwrap().len(), 3);
469 }
470
471 #[test]
472 fn test_remove() {
473 let (store, _dir) = test_store();
474 let id = store
475 .add(&Bookmark::new("ToDelete", "/x.rs", "agent", "/ws"))
476 .unwrap();
477
478 assert_eq!(store.count().unwrap(), 1);
479 assert!(store.remove(id).unwrap());
480 assert_eq!(store.count().unwrap(), 0);
481 }
482
483 #[test]
484 fn test_tag_filter() {
485 let (store, _dir) = test_store();
486
487 store
488 .add(&Bookmark::new("A", "/a.rs", "a", "/w").with_tags("rust"))
489 .unwrap();
490 store
491 .add(&Bookmark::new("B", "/b.rs", "b", "/w").with_tags("python"))
492 .unwrap();
493 store
494 .add(&Bookmark::new("C", "/c.rs", "c", "/w").with_tags("rust, important"))
495 .unwrap();
496
497 let rust_bookmarks = store.list(Some("rust")).unwrap();
498 assert_eq!(rust_bookmarks.len(), 2);
499 }
500
501 #[test]
502 fn test_search() {
503 let (store, _dir) = test_store();
504
505 store
506 .add(&Bookmark::new("Bug fix for auth", "/auth.rs", "a", "/w"))
507 .unwrap();
508 store
509 .add(
510 &Bookmark::new("Feature", "/feat.rs", "a", "/w")
511 .with_note("authentication related"),
512 )
513 .unwrap();
514 store
515 .add(&Bookmark::new("Other", "/other.rs", "a", "/w"))
516 .unwrap();
517
518 let results = store.search("auth").unwrap();
519 assert_eq!(results.len(), 2);
520 }
521
522 #[test]
523 fn test_search_treats_like_metacharacters_literally() {
524 let (store, _dir) = test_store();
525
526 store
527 .add(&Bookmark::new(
528 "Percent 100% complete",
529 "/percent.rs",
530 "a",
531 "/w",
532 ))
533 .unwrap();
534 store
535 .add(&Bookmark::new(
536 "Underscore auth_token",
537 "/underscore.rs",
538 "a",
539 "/w",
540 ))
541 .unwrap();
542 store
543 .add(&Bookmark::new(
544 "Backslash path C:\\tmp",
545 "/backslash.rs",
546 "a",
547 "/w",
548 ))
549 .unwrap();
550 store
551 .add(&Bookmark::new("Plain row", "/plain.rs", "a", "/w"))
552 .unwrap();
553
554 assert_single_search_path(&store, "%", "/percent.rs");
555 assert_single_search_path(&store, "_", "/underscore.rs");
556 assert_single_search_path(&store, "\\", "/backslash.rs");
557 }
558
559 #[test]
560 fn test_is_bookmarked() {
561 let (store, _dir) = test_store();
562
563 store
564 .add(&Bookmark::new("X", "/file.rs", "a", "/w").with_line(10))
565 .unwrap();
566
567 assert!(store.is_bookmarked("/file.rs", Some(10)).unwrap());
568 assert!(!store.is_bookmarked("/file.rs", Some(20)).unwrap());
569 assert!(!store.is_bookmarked("/other.rs", Some(10)).unwrap());
570 }
571
572 #[test]
573 fn test_negative_line_number_from_db_is_sanitized() {
574 let (store, _dir) = test_store();
575 let now = current_timestamp();
576 store
577 .conn
578 .execute_compat(
579 "INSERT INTO bookmarks (title, source_path, line_number, agent, workspace, note, tags, created_at, updated_at, snippet)
580 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
581 params![
582 "NegLine",
583 "/neg.rs",
584 -12_i64,
585 "agent",
586 "/ws",
587 "",
588 "",
589 now,
590 now,
591 ""
592 ],
593 )
594 .unwrap();
595
596 let bookmarks = store.list(None).unwrap();
597 assert_eq!(bookmarks.len(), 1);
598 assert_eq!(bookmarks[0].line_number, None);
599 }
600
601 #[test]
602 fn test_add_rejects_line_number_above_i64_max() {
603 if usize::BITS <= 63 {
604 return;
605 }
606
607 let (store, _dir) = test_store();
608 let too_large_line = (i64::MAX as usize).saturating_add(1);
609 let bookmark =
610 Bookmark::new("HugeLine", "/huge.rs", "agent", "/ws").with_line(too_large_line);
611 let err = store
612 .add(&bookmark)
613 .expect_err("line overflow must be rejected");
614 assert!(err.to_string().contains("line number exceeds i64 range"));
615 }
616
617 #[test]
618 fn test_export_import() {
619 let (store1, _dir1) = test_store();
620 store1
621 .add(&Bookmark::new("A", "/a.rs", "agent", "/w").with_tags("tag1"))
622 .unwrap();
623 store1
624 .add(&Bookmark::new("B", "/b.rs", "agent", "/w").with_tags("tag2"))
625 .unwrap();
626
627 let json = store1.export_json().unwrap();
628
629 let (store2, _dir2) = test_store();
630 let imported = store2.import_json(&json).unwrap();
631 assert_eq!(imported, 2);
632 assert_eq!(store2.count().unwrap(), 2);
633 }
634
635 #[test]
636 fn test_import_deduplicates_null_and_specific_line_numbers_separately() {
637 let (store, _dir) = test_store();
638 let bookmarks = vec![
639 Bookmark::new("Whole file", "/same.rs", "agent", "/w"),
640 Bookmark::new("Specific line", "/same.rs", "agent", "/w").with_line(10),
641 ];
642 let json = serde_json::to_string(&bookmarks).unwrap();
643
644 assert_eq!(store.import_json(&json).unwrap(), 2);
645 assert_eq!(store.import_json(&json).unwrap(), 0);
646 assert_eq!(store.count().unwrap(), 2);
647 assert!(store.is_bookmarked("/same.rs", None).unwrap());
648 assert!(store.is_bookmarked("/same.rs", Some(10)).unwrap());
649 }
650}