Skip to main content

coding_agent_search/
bookmarks.rs

1//! Bookmarks system for saving and annotating search results.
2//!
3//! Provides persistent storage for bookmarked search results with user notes
4//! and tags. Uses a separate `SQLite` database file to avoid schema conflicts.
5
6use 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/// A bookmarked search result with optional note and tags
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Bookmark {
17    /// Unique bookmark ID
18    pub id: i64,
19    /// Title/summary of the bookmarked result
20    pub title: String,
21    /// Path to the source file
22    pub source_path: String,
23    /// Line number in the source (if applicable)
24    pub line_number: Option<usize>,
25    /// Agent that produced this result
26    pub agent: String,
27    /// Workspace path
28    pub workspace: String,
29    /// User's note/annotation
30    pub note: String,
31    /// Comma-separated tags
32    pub tags: String,
33    /// When the bookmark was created (unix millis)
34    pub created_at: i64,
35    /// When the bookmark was last updated (unix millis)
36    pub updated_at: i64,
37    /// Original search snippet (for context)
38    pub snippet: String,
39}
40
41impl Bookmark {
42    /// Create a new bookmark from search result data
43    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, // Set by database on insert
53            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    /// Add a note to the bookmark
67    pub fn with_note(mut self, note: impl Into<String>) -> Self {
68        self.note = note.into();
69        self
70    }
71
72    /// Add tags to the bookmark
73    pub fn with_tags(mut self, tags: impl Into<String>) -> Self {
74        self.tags = tags.into();
75        self
76    }
77
78    /// Set line number
79    pub fn with_line(mut self, line: usize) -> Self {
80        self.line_number = Some(line);
81        self
82    }
83
84    /// Set snippet
85    pub fn with_snippet(mut self, snippet: impl Into<String>) -> Self {
86        self.snippet = snippet.into();
87        self
88    }
89
90    /// Get tags as a vector
91    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    /// Check if bookmark has a specific tag
100    pub fn has_tag(&self, tag: &str) -> bool {
101        self.tag_list().iter().any(|t| t.eq_ignore_ascii_case(tag))
102    }
103}
104
105/// Storage backend for bookmarks using `SQLite`
106pub struct BookmarkStore {
107    conn: Connection,
108}
109
110impl BookmarkStore {
111    /// Open or create a bookmark store at the given path
112    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        // Apply pragmas for performance and concurrency safety
122        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        // Create schema if needed
130        conn.execute_batch(SCHEMA)?;
131
132        Ok(Self { conn })
133    }
134
135    /// Open bookmark store at the default location (`data_dir/bookmarks.db`)
136    pub fn open_default() -> Result<Self> {
137        let path = default_bookmarks_path();
138        Self::open(&path)
139    }
140
141    /// Add a new bookmark
142    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    /// Update an existing bookmark
167    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    /// Remove a bookmark by ID
185    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    /// Get a bookmark by ID
193    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    /// List all bookmarks, optionally filtered by tag
206    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    /// Search bookmarks by text (title, note, snippet)
224    pub fn search(&self, query: &str) -> Result<Vec<Bookmark>> {
225        // Escape SQL LIKE wildcards so they are matched literally
226        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    /// Get all unique tags
245    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    /// Count total bookmarks
259    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    /// Check if a `source_path` + line is already bookmarked
269    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    /// Export all bookmarks to JSON
280    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    /// Import bookmarks from JSON (merges, doesn't overwrite)
286    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            // Check for duplicates
297            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; // Reset ID for new insert
310                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
336/// Convert a database row to a Bookmark
337fn 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
353/// Get the default bookmarks database path
354pub fn default_bookmarks_path() -> PathBuf {
355    crate::default_data_dir().join("bookmarks.db")
356}
357
358/// SQL schema for bookmarks database
359const 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}