1use crate::error::{CursorError, Result};
9use crate::types::{Bubble, ComposerData, ComposerHead, ComposerHeaders};
10use rusqlite::{Connection, OpenFlags, params};
11use std::path::{Path, PathBuf};
12
13pub const COMPOSER_DATA_PREFIX: &str = "composerData:";
15pub const BUBBLE_PREFIX: &str = "bubbleId:";
17pub const CONTENT_PREFIX: &str = "composer.content.";
19pub const HEADERS_KEY: &str = "composer.composerHeaders";
21
22pub struct DbReader {
25 conn: Connection,
26 path: PathBuf,
27}
28
29impl DbReader {
30 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 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 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 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 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 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 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; 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 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 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 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 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 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 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 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 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 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}