Skip to main content

toolpath_cursor/
reader.rs

1//! Read-only SQLite access to Cursor's global `state.vscdb`.
2//!
3//! Cursor uses SQLite WAL mode and writes continuously from the
4//! foreground process. We open with `SQLITE_OPEN_READ_ONLY` (URI form
5//! `?mode=ro`) so we never interfere with a live Cursor process; WAL
6//! readers still observe a consistent snapshot as of transaction start.
7
8use crate::error::{CursorError, Result};
9use crate::types::{Bubble, ComposerData, ComposerHead, ComposerHeaders};
10use rusqlite::{Connection, OpenFlags, params};
11use std::path::{Path, PathBuf};
12
13/// Prefix for the per-composer metadata row.
14pub const COMPOSER_DATA_PREFIX: &str = "composerData:";
15/// Prefix for the per-bubble row.
16pub const BUBBLE_PREFIX: &str = "bubbleId:";
17/// Prefix for content-addressed file blobs.
18pub const CONTENT_PREFIX: &str = "composer.content.";
19/// Cross-workspace composer index key (in `ItemTable`).
20pub const HEADERS_KEY: &str = "composer.composerHeaders";
21
22/// Thin wrapper around a rusqlite connection opened read-only against
23/// Cursor's `state.vscdb`.
24pub struct DbReader {
25    conn: Connection,
26    path: PathBuf,
27}
28
29impl DbReader {
30    /// Open Cursor's global state database read-only.
31    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
32        let path = path.as_ref();
33        if !path.exists() {
34            return Err(CursorError::DatabaseNotFound(path.to_path_buf()));
35        }
36        let conn = Connection::open_with_flags(
37            path,
38            OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
39        )?;
40        Ok(Self {
41            conn,
42            path: path.to_path_buf(),
43        })
44    }
45
46    pub fn path(&self) -> &Path {
47        &self.path
48    }
49
50    /// Read `ItemTable.composer.composerHeaders`. Returns the parsed
51    /// blob, or an empty `ComposerHeaders` when the key is missing.
52    pub fn read_composer_headers(&self) -> Result<ComposerHeaders> {
53        let raw: Option<String> = self
54            .conn
55            .query_row(
56                "SELECT value FROM ItemTable WHERE key = ?1",
57                params![HEADERS_KEY],
58                |r| r.get(0),
59            )
60            .or_else(|e| {
61                if matches!(e, rusqlite::Error::QueryReturnedNoRows) {
62                    Ok(None)
63                } else {
64                    Err(e)
65                }
66            })?;
67        match raw {
68            Some(s) => serde_json::from_str(&s).map_err(CursorError::from),
69            None => Ok(ComposerHeaders::default()),
70        }
71    }
72
73    /// All composer ids known to the DB, in the order they appear in
74    /// `composer.composerHeaders.allComposers`. Composers with no
75    /// bubbles are included — call [`Self::composer_has_bubbles`] to filter
76    /// drafts.
77    pub fn list_composer_ids(&self) -> Result<Vec<String>> {
78        Ok(self
79            .read_composer_headers()?
80            .all_composers
81            .into_iter()
82            .map(|h| h.composer_id)
83            .collect())
84    }
85
86    /// Look up one header by composer id, when present.
87    pub fn read_composer_head(&self, composer_id: &str) -> Result<Option<ComposerHead>> {
88        Ok(self
89            .read_composer_headers()?
90            .all_composers
91            .into_iter()
92            .find(|h| h.composer_id == composer_id))
93    }
94
95    /// Read `cursorDiskKV.composerData:<composer_id>`.
96    pub fn read_composer_data(&self, composer_id: &str) -> Result<Option<ComposerData>> {
97        let key = format!("{COMPOSER_DATA_PREFIX}{composer_id}");
98        let raw: Option<String> = self
99            .conn
100            .query_row(
101                "SELECT value FROM cursorDiskKV WHERE key = ?1",
102                params![key],
103                |r| r.get(0),
104            )
105            .or_else(|e| {
106                if matches!(e, rusqlite::Error::QueryReturnedNoRows) {
107                    Ok(None)
108                } else {
109                    Err(e)
110                }
111            })?;
112        match raw {
113            Some(s) => serde_json::from_str(&s)
114                .map(Some)
115                .map_err(|e| CursorError::MalformedPayload {
116                    what: format!("composerData:{composer_id}"),
117                    detail: e.to_string(),
118                }),
119            None => Ok(None),
120        }
121    }
122
123    /// Read a single bubble by composer + bubble id. `None` when the
124    /// row is absent (drafts referenced by header but never saved
125    /// fall in this case).
126    pub fn read_bubble(&self, composer_id: &str, bubble_id: &str) -> Result<Option<Bubble>> {
127        let key = format!("{BUBBLE_PREFIX}{composer_id}:{bubble_id}");
128        let raw: Option<String> = self
129            .conn
130            .query_row(
131                "SELECT value FROM cursorDiskKV WHERE key = ?1",
132                params![key],
133                |r| r.get(0),
134            )
135            .or_else(|e| {
136                if matches!(e, rusqlite::Error::QueryReturnedNoRows) {
137                    Ok(None)
138                } else {
139                    Err(e)
140                }
141            })?;
142        match raw {
143            Some(s) => match serde_json::from_str::<Bubble>(&s) {
144                Ok(b) => Ok(Some(b)),
145                Err(e) => {
146                    eprintln!(
147                        "Warning: bubble {composer_id}:{bubble_id} malformed: {e}; skipping"
148                    );
149                    Ok(None)
150                }
151            },
152            None => Ok(None),
153        }
154    }
155
156    /// All bubble ids stored for a composer. The order from this
157    /// function is *not* meaningful — combine with the composer's
158    /// `fullConversationHeadersOnly` to order them as the UI renders.
159    pub fn list_bubble_ids_for_composer(&self, composer_id: &str) -> Result<Vec<String>> {
160        let pattern = format!("{BUBBLE_PREFIX}{composer_id}:%");
161        let mut stmt = self
162            .conn
163            .prepare("SELECT key FROM cursorDiskKV WHERE key LIKE ?1")?;
164        let rows = stmt
165            .query_map(params![pattern], |r| r.get::<_, String>(0))?
166            .collect::<rusqlite::Result<Vec<_>>>()?;
167        let prefix_len = BUBBLE_PREFIX.len() + composer_id.len() + 1; // include ':'
168        Ok(rows
169            .into_iter()
170            .filter_map(|k| {
171                if k.len() > prefix_len {
172                    Some(k[prefix_len..].to_string())
173                } else {
174                    None
175                }
176            })
177            .collect())
178    }
179
180    /// Cheap "does this composer have any rendered bubbles?" check.
181    pub fn composer_has_bubbles(&self, composer_id: &str) -> Result<bool> {
182        let pattern = format!("{BUBBLE_PREFIX}{composer_id}:%");
183        let count: i64 = self.conn.query_row(
184            "SELECT count(*) FROM cursorDiskKV WHERE key LIKE ?1",
185            params![pattern],
186            |r| r.get(0),
187        )?;
188        Ok(count > 0)
189    }
190
191    /// Read a content-addressed file blob. `hash` is the substring
192    /// after `composer.content.`. UTF-8 decoded; non-text blobs are
193    /// returned as `Some(<lossy>)`.
194    pub fn read_content_blob(&self, hash: &str) -> Result<Option<String>> {
195        let key = format!("{CONTENT_PREFIX}{hash}");
196        let raw = self.read_value_bytes(&key)?;
197        Ok(raw.map(|b| String::from_utf8_lossy(&b).into_owned()))
198    }
199
200    /// Read the raw bytes of one `cursorDiskKV` row, regardless of
201    /// whether SQLite has stored it as TEXT or BLOB. (`cursor.content.*`
202    /// rows we've inspected are TEXT when ASCII and BLOB otherwise;
203    /// SQLite's typed accessors error on a mismatch, so we use the
204    /// untyped `ValueRef` and coerce.)
205    fn read_value_bytes(&self, key: &str) -> Result<Option<Vec<u8>>> {
206        let mut stmt = self
207            .conn
208            .prepare("SELECT value FROM cursorDiskKV WHERE key = ?1")?;
209        let mut rows = stmt.query(params![key])?;
210        match rows.next()? {
211            None => Ok(None),
212            Some(row) => {
213                let vref = row.get_ref(0)?;
214                let bytes = match vref {
215                    rusqlite::types::ValueRef::Null => Vec::new(),
216                    rusqlite::types::ValueRef::Text(b) | rusqlite::types::ValueRef::Blob(b) => {
217                        b.to_vec()
218                    }
219                    rusqlite::types::ValueRef::Integer(n) => n.to_string().into_bytes(),
220                    rusqlite::types::ValueRef::Real(f) => f.to_string().into_bytes(),
221                };
222                Ok(Some(bytes))
223            }
224        }
225    }
226
227    /// Compose a complete session: header (when present), data, and
228    /// the bubble bodies in `fullConversationHeadersOnly` order
229    /// (filtered to bubbles that actually exist on disk). Bubbles
230    /// listed in the header manifest but missing from `cursorDiskKV`
231    /// are silently skipped — they're rendered as placeholders in the
232    /// UI but carry no content.
233    pub fn load_session(&self, composer_id: &str) -> Result<crate::types::CursorSession> {
234        let data = self
235            .read_composer_data(composer_id)?
236            .ok_or_else(|| CursorError::ComposerNotFound(composer_id.to_string()))?;
237        let head = self.read_composer_head(composer_id)?;
238
239        // Load bubbles in header order. Then sweep any leftover
240        // bubbles that exist on disk but aren't in the manifest (we've
241        // seen this when Cursor wrote the bubble before flushing the
242        // composerData blob) and append them ordered by `createdAt`.
243        let mut bubbles: Vec<Bubble> = Vec::new();
244        let mut seen_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
245        for h in &data.full_conversation_headers_only {
246            if let Some(b) = self.read_bubble(composer_id, &h.bubble_id)? {
247                seen_ids.insert(b.bubble_id.clone());
248                bubbles.push(b);
249            }
250        }
251        let on_disk = self.list_bubble_ids_for_composer(composer_id)?;
252        let mut leftovers: Vec<Bubble> = on_disk
253            .iter()
254            .filter(|bid| !seen_ids.contains(bid.as_str()))
255            .filter_map(|bid| self.read_bubble(composer_id, bid).ok().flatten())
256            .collect();
257        leftovers.sort_by(|a, b| a.created_at.cmp(&b.created_at));
258        bubbles.extend(leftovers);
259
260        // Load content blobs referenced by tool results.
261        let mut content_blobs = std::collections::HashMap::new();
262        for b in &bubbles {
263            let Some(tf) = &b.tool_former_data else { continue };
264            let Ok(Some(result)) = tf.parse_result() else {
265                continue;
266            };
267            for field in ["beforeContentId", "afterContentId"] {
268                let Some(raw) = result.get(field).and_then(|v| v.as_str()) else {
269                    continue;
270                };
271                let Some(hash) = raw.strip_prefix(CONTENT_PREFIX) else {
272                    continue;
273                };
274                if content_blobs.contains_key(hash) {
275                    continue;
276                }
277                if let Some(body) = self.read_content_blob(hash)? {
278                    content_blobs.insert(hash.to_string(), body);
279                }
280            }
281        }
282
283        Ok(crate::types::CursorSession {
284            head,
285            data,
286            bubbles,
287            content_blobs,
288            transcript_path: None,
289        })
290    }
291}
292
293#[cfg(test)]
294pub(crate) mod tests {
295    use super::*;
296    use crate::types::{BUBBLE_TYPE_ASSISTANT, BUBBLE_TYPE_USER};
297    use rusqlite::Connection;
298    use tempfile::NamedTempFile;
299
300    pub(crate) fn fixture_db(setup_sql: &str) -> NamedTempFile {
301        let f = NamedTempFile::new().unwrap();
302        let conn = Connection::open(f.path()).unwrap();
303        conn.execute_batch(
304            r#"
305            CREATE TABLE ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB);
306            CREATE TABLE cursorDiskKV (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB);
307            "#,
308        )
309        .unwrap();
310        conn.execute_batch(setup_sql).unwrap();
311        f
312    }
313
314    /// One composer ("c1") with a user bubble, an assistant text
315    /// bubble, and an `edit_file_v2` tool bubble. Two content blobs
316    /// (empty before, populated after).
317    pub(crate) const BASIC_FIXTURE: &str = r#"
318        INSERT INTO ItemTable (key, value) VALUES
319          ('composer.composerHeaders',
320           '{"allComposers":[{"type":"head","composerId":"c1","name":"T","createdAt":1000,"lastUpdatedAt":2000,"unifiedMode":"agent","workspaceIdentifier":{"id":"ws1","uri":{"$mid":1,"fsPath":"/p","external":"file:///p","path":"/p","scheme":"file"}}}]}');
321        INSERT INTO cursorDiskKV (key, value) VALUES
322          ('composerData:c1',
323           '{"_v":16,"composerId":"c1","name":"T","createdAt":1000,"lastUpdatedAt":2000,"isAgentic":true,"unifiedMode":"agent","agentBackend":"cursor-agent","modelConfig":{"modelName":"default","maxMode":false,"selectedModels":[{"modelId":"default","parameters":[]}]},"fullConversationHeadersOnly":[{"bubbleId":"u1","type":1},{"bubbleId":"a1","type":2},{"bubbleId":"t1","type":2}]}'),
324          ('bubbleId:c1:u1',
325           '{"_v":3,"type":1,"bubbleId":"u1","createdAt":"2026-06-01T00:00:00.000Z","text":"hello","conversationState":"~"}'),
326          ('bubbleId:c1:a1',
327           '{"_v":3,"type":2,"bubbleId":"a1","createdAt":"2026-06-01T00:00:01.000Z","text":"hi back","modelInfo":{"modelName":"claude-opus-4-7"},"tokenCount":{"inputTokens":10,"outputTokens":5}}'),
328          ('bubbleId:c1:t1',
329           '{"_v":3,"type":2,"bubbleId":"t1","createdAt":"2026-06-01T00:00:02.000Z","text":"","capabilityType":15,"toolFormerData":{"tool":38,"toolIndex":0,"modelCallId":"","toolCallId":"tool_a","status":"completed","name":"edit_file_v2","params":"{\"relativeWorkspacePath\":\"/p/x.rs\"}","result":"{\"beforeContentId\":\"composer.content.e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"afterContentId\":\"composer.content.ABC\"}"}}'),
330          ('composer.content.e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', ''),
331          ('composer.content.ABC', 'fn main() {}');
332    "#;
333
334    #[test]
335    fn opens_and_lists_composers() {
336        let f = fixture_db(BASIC_FIXTURE);
337        let r = DbReader::open(f.path()).unwrap();
338        let ids = r.list_composer_ids().unwrap();
339        assert_eq!(ids, vec!["c1"]);
340    }
341
342    #[test]
343    fn reads_composer_data() {
344        let f = fixture_db(BASIC_FIXTURE);
345        let r = DbReader::open(f.path()).unwrap();
346        let cd = r.read_composer_data("c1").unwrap().unwrap();
347        assert_eq!(cd.v, 16);
348        assert_eq!(cd.full_conversation_headers_only.len(), 3);
349        assert_eq!(cd.agent_backend.as_deref(), Some("cursor-agent"));
350    }
351
352    #[test]
353    fn reads_bubble_and_misses_gracefully() {
354        let f = fixture_db(BASIC_FIXTURE);
355        let r = DbReader::open(f.path()).unwrap();
356        let b = r.read_bubble("c1", "u1").unwrap().unwrap();
357        assert_eq!(b.kind, BUBBLE_TYPE_USER);
358        assert_eq!(b.text, "hello");
359
360        assert!(r.read_bubble("c1", "missing").unwrap().is_none());
361    }
362
363    #[test]
364    fn lists_bubble_ids_and_has_bubbles() {
365        let f = fixture_db(BASIC_FIXTURE);
366        let r = DbReader::open(f.path()).unwrap();
367        let mut ids = r.list_bubble_ids_for_composer("c1").unwrap();
368        ids.sort();
369        assert_eq!(ids, vec!["a1", "t1", "u1"]);
370        assert!(r.composer_has_bubbles("c1").unwrap());
371        assert!(!r.composer_has_bubbles("c-empty").unwrap());
372    }
373
374    #[test]
375    fn reads_content_blob() {
376        let f = fixture_db(BASIC_FIXTURE);
377        let r = DbReader::open(f.path()).unwrap();
378        let empty = r
379            .read_content_blob("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
380            .unwrap()
381            .unwrap();
382        assert_eq!(empty, "");
383        let body = r.read_content_blob("ABC").unwrap().unwrap();
384        assert_eq!(body, "fn main() {}");
385        assert!(r.read_content_blob("ZZZ").unwrap().is_none());
386    }
387
388    #[test]
389    fn load_session_orders_bubbles_and_fetches_blobs() {
390        let f = fixture_db(BASIC_FIXTURE);
391        let r = DbReader::open(f.path()).unwrap();
392        let s = r.load_session("c1").unwrap();
393        assert_eq!(s.bubbles.len(), 3);
394        assert_eq!(s.bubbles[0].kind, BUBBLE_TYPE_USER);
395        assert_eq!(s.bubbles[1].kind, BUBBLE_TYPE_ASSISTANT);
396        assert_eq!(s.bubbles[2].kind, BUBBLE_TYPE_ASSISTANT);
397        // Both before + after blobs cached.
398        assert_eq!(s.content_blobs.len(), 2);
399        assert_eq!(s.content_blob("ABC"), Some("fn main() {}"));
400    }
401
402    #[test]
403    fn load_session_missing_composer_errors() {
404        let f = fixture_db(BASIC_FIXTURE);
405        let r = DbReader::open(f.path()).unwrap();
406        let err = r.load_session("nope").unwrap_err();
407        assert!(matches!(err, CursorError::ComposerNotFound(_)));
408    }
409
410    #[test]
411    fn malformed_composer_data_surfaces_error() {
412        let setup = r#"INSERT INTO cursorDiskKV (key, value) VALUES ('composerData:bad', '{not json}');"#;
413        let f = fixture_db(setup);
414        let r = DbReader::open(f.path()).unwrap();
415        let err = r.read_composer_data("bad").unwrap_err();
416        assert!(matches!(err, CursorError::MalformedPayload { .. }));
417    }
418
419    #[test]
420    fn load_session_picks_up_orphaned_bubble() {
421        // composerData lists only one bubble; the second exists on
422        // disk but isn't in fullConversationHeadersOnly. The loader
423        // appends it ordered by createdAt.
424        let setup = r#"
425            INSERT INTO cursorDiskKV (key, value) VALUES
426              ('composerData:c2', '{"_v":16,"composerId":"c2","fullConversationHeadersOnly":[{"bubbleId":"u1","type":1}]}'),
427              ('bubbleId:c2:u1', '{"_v":3,"type":1,"bubbleId":"u1","createdAt":"2026-06-01T00:00:00.000Z","text":"first"}'),
428              ('bubbleId:c2:u2', '{"_v":3,"type":1,"bubbleId":"u2","createdAt":"2026-06-01T00:00:01.000Z","text":"second"}');
429        "#;
430        let f = fixture_db(setup);
431        let r = DbReader::open(f.path()).unwrap();
432        let s = r.load_session("c2").unwrap();
433        assert_eq!(s.bubbles.len(), 2);
434        assert_eq!(s.bubbles[0].bubble_id, "u1");
435        assert_eq!(s.bubbles[1].bubble_id, "u2");
436    }
437}