diff --git a/src/harness/cursor_desktop.rs b/src/harness/cursor_desktop.rs index 8158a93..8c5f7cf 100644 --- a/src/harness/cursor_desktop.rs +++ b/src/harness/cursor_desktop.rs @@ -1283,22 +1283,28 @@ impl Store for CursorDesktopStore { fn delete(&self, reference: &String) -> Result<()> { super::checked_id_component(CursorDesktop::NAME, reference)?; let conn = Connection::open(self.db_path()).map_err(sqlite_err)?; - let deleted: usize = conn - .execute( + let deleted_headers = if has_table(&conn, "composerHeaders")? { + conn.execute( "DELETE FROM composerHeaders WHERE composerId = ?1", params![reference], ) + .map_err(sqlite_err)? + } else { + 0 + }; + let escaped = escape_like_pattern(reference); + let deleted_kv: usize = conn + .execute( + "DELETE FROM cursorDiskKV WHERE key = ?1 OR key LIKE ?2 ESCAPE '\\' OR key LIKE ?3 ESCAPE '\\' OR key LIKE ?4 ESCAPE '\\'", + params![ + format!("composerData:{reference}"), + format!("bubbleId:{escaped}:%"), + format!("%:{escaped}"), + format!("%:{escaped}:%"), + ], + ) .map_err(sqlite_err)?; - conn.execute( - "DELETE FROM cursorDiskKV WHERE key = ?1 OR key LIKE ?2 OR key LIKE ?3", - params![ - format!("composerData:{reference}"), - format!("bubbleId:{reference}:%"), - format!("%:{reference}%"), - ], - ) - .map_err(sqlite_err)?; - if deleted == 0 { + if deleted_headers == 0 && deleted_kv == 0 { return Err(Error::Malformed { harness: CursorDesktop::NAME, detail: format!("no such session: {reference}"), @@ -1439,6 +1445,18 @@ fn discover_from_headers(conn: &Connection) -> Result>> { .collect()) } +#[cfg(feature = "opencode")] +fn escape_like_pattern(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + if matches!(c, '%' | '_' | '\\') { + out.push('\\'); + } + out.push(c); + } + out +} + /// Half-open key range covering every key that starts with `prefix`: the /// upper bound is the prefix with its last character stepped up by one /// code point, which sorts after every extension of the prefix under diff --git a/tests/integration/cursor_desktop.rs b/tests/integration/cursor_desktop.rs index 4d8e7a2..335045b 100644 --- a/tests/integration/cursor_desktop.rs +++ b/tests/integration/cursor_desktop.rs @@ -551,6 +551,20 @@ fn pending_tool_call_yields_no_result_message() { ); } +#[cfg(feature = "opencode")] +#[test] +fn delete_roundtrip_removes_session_and_fails_on_duplicate() { + let dir = tempfile::tempdir().unwrap(); + let store = CursorDesktopStore::new(dir.path()); + let saved = store.save(&sample_transcript()).unwrap(); + + assert_eq!(store.discover().unwrap().len(), 1); + store.delete(&saved.reference).unwrap(); + assert_eq!(store.discover().unwrap().len(), 0); + + assert!(store.delete(&saved.reference).is_err()); +} + #[test] fn edit_tool_with_replace_all_survives_fixpoint() { let mut common = fixpoint_common(); @@ -676,6 +690,187 @@ fn edit_and_write_tools_normalize_and_denormalize_losslessly() { assert_eq!(back.body, common.body); } +#[cfg(feature = "opencode")] +#[test] +fn delete_succeeds_on_database_without_composer_headers_table() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("globalStorage").join("state.vscdb"); + std::fs::create_dir_all(db_path.parent().unwrap()).unwrap(); + + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute_batch( + "CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT); + INSERT INTO cursorDiskKV VALUES ('composerData:pre-table-1', '{}'); + INSERT INTO cursorDiskKV VALUES ('bubbleId:pre-table-1:b1', '{}');", + ) + .unwrap(); + + let store = CursorDesktopStore::new(dir.path()); + store.delete(&"pre-table-1".to_string()).unwrap(); + + let count: i64 = conn + .query_row( + "SELECT count(*) FROM cursorDiskKV WHERE key LIKE '%pre-table-1%'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 0); + + // Deleting again should fail with no such session + assert!(store.delete(&"pre-table-1".to_string()).is_err()); +} + +#[cfg(feature = "opencode")] +#[test] +fn delete_succeeds_on_kv_only_session_in_modern_database() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("globalStorage").join("state.vscdb"); + std::fs::create_dir_all(db_path.parent().unwrap()).unwrap(); + + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute_batch( + "CREATE TABLE composerHeaders (composerId TEXT PRIMARY KEY, recency INTEGER); + CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT); + INSERT INTO cursorDiskKV VALUES ('composerData:kv-only-1', '{}'); + INSERT INTO cursorDiskKV VALUES ('bubbleId:kv-only-1:b1', '{}');", + ) + .unwrap(); + + let store = CursorDesktopStore::new(dir.path()); + store.delete(&"kv-only-1".to_string()).unwrap(); + + let count: i64 = conn + .query_row( + "SELECT count(*) FROM cursorDiskKV WHERE key LIKE '%kv-only-1%'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 0); + + assert!(store.delete(&"kv-only-1".to_string()).is_err()); +} + +#[cfg(feature = "opencode")] +#[test] +fn delete_exact_session_id_does_not_delete_prefixed_sibling() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("globalStorage").join("state.vscdb"); + std::fs::create_dir_all(db_path.parent().unwrap()).unwrap(); + + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute_batch( + "CREATE TABLE composerHeaders (composerId TEXT PRIMARY KEY, recency INTEGER); + CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT); + INSERT INTO composerHeaders VALUES ('session-1', 100); + INSERT INTO composerHeaders VALUES ('session-10', 200); + INSERT INTO cursorDiskKV VALUES ('composerData:session-1', '{}'); + INSERT INTO cursorDiskKV VALUES ('bubbleId:session-1:b1', '{}'); + INSERT INTO cursorDiskKV VALUES ('checkpointId:session-1:c1', '{}'); + INSERT INTO cursorDiskKV VALUES ('composerData:session-10', '{}'); + INSERT INTO cursorDiskKV VALUES ('bubbleId:session-10:b1', '{}'); + INSERT INTO cursorDiskKV VALUES ('checkpointId:session-10:c1', '{}');", + ) + .unwrap(); + + let store = CursorDesktopStore::new(dir.path()); + store.delete(&"session-1".to_string()).unwrap(); + + let count_s10_headers: i64 = conn + .query_row( + "SELECT count(*) FROM composerHeaders WHERE composerId = 'session-10'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count_s10_headers, 1); + + let remaining_s10_keys: i64 = conn + .query_row( + "SELECT count(*) FROM cursorDiskKV WHERE key LIKE '%session-10%'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(remaining_s10_keys, 3); + + let remaining_target_keys: i64 = conn + .query_row( + "SELECT count(*) FROM cursorDiskKV WHERE key LIKE '%session-1%' AND key NOT LIKE '%session-10%'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(remaining_target_keys, 0); +} + +#[cfg(feature = "opencode")] +#[test] +fn delete_escapes_like_wildcards_in_session_id() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("globalStorage").join("state.vscdb"); + std::fs::create_dir_all(db_path.parent().unwrap()).unwrap(); + + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute_batch( + "CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT); + INSERT INTO cursorDiskKV VALUES ('composerData:session_1', '{}'); + INSERT INTO cursorDiskKV VALUES ('bubbleId:session_1:b1', '{}'); + INSERT INTO cursorDiskKV VALUES ('composerData:sessionX1', '{}'); + INSERT INTO cursorDiskKV VALUES ('bubbleId:sessionX1:b1', '{}'); + INSERT INTO cursorDiskKV VALUES ('composerData:session%1', '{}'); + INSERT INTO cursorDiskKV VALUES ('bubbleId:session%1:b1', '{}'); + INSERT INTO cursorDiskKV VALUES ('composerData:sessionANYTHING1', '{}'); + INSERT INTO cursorDiskKV VALUES ('bubbleId:sessionANYTHING1:b1', '{}');", + ) + .unwrap(); + + let store = CursorDesktopStore::new(dir.path()); + + // Delete session_1: should NOT delete sessionX1 or sessionANYTHING1 or session%1 + store.delete(&"session_1".to_string()).unwrap(); + + let count_session_x1: i64 = conn + .query_row( + "SELECT count(*) FROM cursorDiskKV WHERE key LIKE '%sessionX1%'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count_session_x1, 2); + + let count_deleted_underscore: i64 = conn + .query_row( + "SELECT count(*) FROM cursorDiskKV WHERE key LIKE '%session\\_1%' ESCAPE '\\'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count_deleted_underscore, 0); + + // Delete session%1: should NOT delete sessionANYTHING1 + store.delete(&"session%1".to_string()).unwrap(); + + let count_session_anything1: i64 = conn + .query_row( + "SELECT count(*) FROM cursorDiskKV WHERE key LIKE '%sessionANYTHING1%'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count_session_anything1, 2); + + let count_deleted_percent: i64 = conn + .query_row( + "SELECT count(*) FROM cursorDiskKV WHERE key LIKE '%session\\%1%' ESCAPE '\\'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count_deleted_percent, 0); +} + #[cfg(feature = "opencode")] #[test] fn store_resolves_workspace_id_with_percent_encoding_and_spaces() {