Skip to main content

cursor_helper/cursor/
storage.rs

1//! Global storage operations
2//!
3//! Handles updates to ~/Library/Application Support/Cursor/User/globalStorage/storage.json
4
5use anyhow::{Context, Result};
6use rusqlite::{Connection, params};
7use serde::Deserialize;
8use serde_json::Value;
9use std::collections::HashMap;
10use std::fs;
11use std::path::Path;
12
13use super::sqlite_value::Utf8SqlValue;
14
15/// Update workspace references in storage.json
16///
17/// This updates:
18/// - backupWorkspaces.folders[].folderUri
19/// - profileAssociations.workspaces (key rename)
20pub fn update_storage_json<P: AsRef<Path>>(
21    storage_path: P,
22    old_uri: &str,
23    new_uri: &str,
24    dry_run: bool,
25) -> Result<bool> {
26    let storage_path = storage_path.as_ref();
27
28    if !storage_path.exists() {
29        return Ok(false);
30    }
31
32    let content = fs::read_to_string(storage_path)
33        .with_context(|| format!("Failed to read: {}", storage_path.display()))?;
34
35    let mut json: Value = serde_json::from_str(&content).context("Failed to parse storage.json")?;
36
37    // Update backupWorkspaces.folders[].folderUri
38    let folders_modified = json
39        .get_mut("backupWorkspaces")
40        .and_then(|b| b.get_mut("folders"))
41        .and_then(|f| f.as_array_mut())
42        .map(|arr| {
43            arr.iter_mut()
44                .filter_map(|folder| folder.get_mut("folderUri"))
45                .filter(|uri| uri.as_str() == Some(old_uri))
46                .fold(false, |_, uri| {
47                    *uri = Value::String(new_uri.to_string());
48                    true
49                })
50        })
51        .unwrap_or(false);
52
53    // Update profileAssociations.workspaces (rename key)
54    let assoc_modified = json
55        .get_mut("profileAssociations")
56        .and_then(|a| a.get_mut("workspaces"))
57        .and_then(|w| w.as_object_mut())
58        .and_then(|obj| obj.remove(old_uri).map(|v| (obj, v)))
59        .map(|(obj, value)| {
60            obj.insert(new_uri.to_string(), value);
61            true
62        })
63        .unwrap_or(false);
64
65    let modified = folders_modified || assoc_modified;
66
67    if modified && !dry_run {
68        let new_content = serde_json::to_string_pretty(&json)?;
69        fs::write(storage_path, new_content)
70            .with_context(|| format!("Failed to write: {}", storage_path.display()))?;
71    }
72
73    Ok(modified)
74}
75
76/// Update workspace references embedded in global `state.vscdb`.
77///
78/// Cursor stores several workspace mappings and references in the global database:
79/// - `ItemTable.value` / `ItemTable.key`
80/// - `cursorDiskKV.key` / `cursorDiskKV.value`
81///
82/// Updates any exact path/hash strings from old values to new ones, which
83/// helps keep move/copy operations from leaving stale workspace IDs behind.
84#[allow(clippy::too_many_arguments)]
85pub fn update_global_state_db<P: AsRef<Path>>(
86    state_db: P,
87    old_path: &str,
88    new_path: &str,
89    old_uri: &str,
90    new_uri: &str,
91    old_workspace_hash: &str,
92    new_workspace_hash: &str,
93    dry_run: bool,
94) -> Result<bool> {
95    let state_db = state_db.as_ref();
96
97    if !state_db.exists() {
98        return Ok(false);
99    }
100
101    let mut conn = Connection::open(state_db)
102        .with_context(|| format!("Failed to open global state DB: {}", state_db.display()))?;
103
104    let mut modified = false;
105
106    // Replace path, URI, and workspace hash references in all known text columns.
107    // This is intentionally conservative to avoid schema-specific assumptions and
108    // avoids overlap by applying all replacements through placeholders in-memory.
109    let replacements = [
110        (old_path, new_path),
111        (old_uri, new_uri),
112        (old_workspace_hash, new_workspace_hash),
113    ];
114    let normalized_replacements: Vec<(String, String)> = replacements
115        .iter()
116        .map(|(old, new)| (old.to_string(), new.to_string()))
117        .collect();
118    let targets = [
119        ("ItemTable", "key"),
120        ("ItemTable", "value"),
121        ("cursorDiskKV", "key"),
122        ("cursorDiskKV", "value"),
123    ];
124
125    if dry_run {
126        for (table, column) in targets {
127            if !table_exists(&conn, table)? || !column_exists(&conn, table, column)? {
128                continue;
129            }
130
131            let query = format!("SELECT {column} FROM {table}");
132            let mut stmt = conn.prepare(&query)?;
133            let mut rows = stmt.query([])?;
134
135            while let Some(row) = rows.next()? {
136                let value = Utf8SqlValue::from_row(row, 0)?;
137
138                let Some(value) = value else {
139                    continue;
140                };
141
142                if !has_workspace_scoped_reference(value.as_str(), &normalized_replacements) {
143                    continue;
144                }
145
146                let normalized =
147                    normalize_text_replacements(value.as_str(), &normalized_replacements);
148                if normalized != value.as_str() {
149                    return Ok(true);
150                }
151            }
152        }
153
154        return Ok(false);
155    }
156
157    let tx = conn
158        .transaction()
159        .with_context(|| format!("Failed to start transaction for: {}", state_db.display()))?;
160
161    for (table, column) in targets {
162        if !table_exists(&tx, table)? || !column_exists(&tx, table, column)? {
163            continue;
164        }
165
166        let query = format!("SELECT rowid, {column} FROM {table}");
167        let mut stmt = tx.prepare(&query)?;
168        let mut rows = stmt.query([])?;
169        let mut pending_updates: Vec<(i64, Utf8SqlValue)> = Vec::new();
170
171        while let Some(row) = rows.next()? {
172            let rowid: i64 = row.get(0)?;
173            let value = Utf8SqlValue::from_row(row, 1)?;
174
175            let Some(value) = value else {
176                continue;
177            };
178
179            if !has_workspace_scoped_reference(value.as_str(), &normalized_replacements) {
180                continue;
181            }
182
183            let normalized = normalize_text_replacements(value.as_str(), &normalized_replacements);
184
185            if normalized == value.as_str() {
186                continue;
187            }
188
189            let normalized = match value {
190                Utf8SqlValue::Text(_) => Utf8SqlValue::Text(normalized),
191                Utf8SqlValue::Blob(_) => Utf8SqlValue::Blob(normalized),
192            };
193            pending_updates.push((rowid, normalized));
194        }
195
196        for (rowid, normalized) in pending_updates {
197            normalized
198                .write_back(
199                    &tx,
200                    &format!("UPDATE {table} SET {column} = ?1 WHERE rowid = ?2"),
201                    rowid,
202                )
203                .with_context(|| {
204                    format!("Failed to update {table}.{column} for row {rowid} in global state DB")
205                })?;
206            modified = true;
207        }
208    }
209
210    tx.commit()
211        .context("Failed to commit global state DB update")?;
212
213    Ok(modified)
214}
215
216fn has_workspace_scoped_reference(value: &str, replacements: &[(String, String)]) -> bool {
217    let candidates: Vec<&str> = replacements
218        .iter()
219        .filter_map(|(old, new)| (old != new).then_some(old.as_str()))
220        .collect();
221
222    candidates
223        .iter()
224        .any(|old| has_safe_suffix_match(value, old))
225}
226
227fn has_safe_suffix_match(value: &str, pattern: &str) -> bool {
228    if pattern.is_empty() {
229        return false;
230    }
231
232    let mut offset = 0usize;
233    while let Some(pos) = value[offset..].find(pattern) {
234        let absolute_pos = offset + pos;
235        let suffix = value[absolute_pos + pattern.len()..].chars().next();
236
237        if is_workspace_value_suffix_terminator(suffix) {
238            return true;
239        }
240
241        offset = absolute_pos + 1;
242    }
243
244    false
245}
246
247fn is_workspace_value_suffix_terminator(suffix: Option<char>) -> bool {
248    match suffix {
249        None => true,
250        Some(suffix) => {
251            !suffix.is_ascii_alphanumeric()
252                && suffix != '_'
253                && suffix != '-'
254                && suffix != '.'
255                && suffix != '%'
256        }
257    }
258}
259
260fn normalize_text_replacements(value: &str, replacements: &[(String, String)]) -> String {
261    let normalized_replacements: Vec<(&str, &str)> = replacements
262        .iter()
263        .filter_map(|(old, new)| (old != new).then_some((old.as_str(), new.as_str())))
264        .collect();
265
266    if normalized_replacements.is_empty() {
267        return value.to_string();
268    }
269
270    let mut token_seed = 0usize;
271    let mut tokens = Vec::new();
272
273    let mut staged = value.to_string();
274    for (old, _) in &normalized_replacements {
275        let mut token = format!("__CURSOR_HELPER_REPLACE_TOKEN_{token_seed}__");
276        while staged.contains(&token) || tokens.iter().any(|(old_token, _)| old_token == &token) {
277            token_seed += 1;
278            token = format!("__CURSOR_HELPER_REPLACE_TOKEN_{token_seed}__");
279        }
280
281        staged = replace_workspace_scoped_matches(&staged, old, &token);
282        tokens.push((token, *old));
283        token_seed += 1;
284    }
285
286    let mut normalized = staged;
287    for (token, old_value) in tokens {
288        if let Some((_, new_value)) = normalized_replacements
289            .iter()
290            .find(|(old, _)| old == &old_value)
291        {
292            normalized = normalized.replace(&token, new_value);
293        }
294    }
295
296    normalized
297}
298
299fn replace_workspace_scoped_matches(value: &str, pattern: &str, replacement: &str) -> String {
300    if pattern.is_empty() {
301        return value.to_string();
302    }
303
304    let mut offset = 0usize;
305    let mut normalized = String::with_capacity(value.len());
306
307    while let Some(pos) = value[offset..].find(pattern) {
308        let absolute_pos = offset + pos;
309
310        normalized.push_str(&value[offset..absolute_pos]);
311
312        let next_offset = absolute_pos + pattern.len();
313        let suffix = value[next_offset..].chars().next();
314        if is_workspace_value_suffix_terminator(suffix) {
315            normalized.push_str(replacement);
316        } else {
317            normalized.push_str(pattern);
318        }
319
320        offset = next_offset;
321    }
322
323    normalized.push_str(&value[offset..]);
324    normalized
325}
326
327fn table_exists(conn: &Connection, table: &str) -> Result<bool> {
328    let count: i64 = conn
329        .query_row(
330            "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name = ?1",
331            params![table],
332            |row| row.get(0),
333        )
334        .context("Failed to query sqlite_master")?;
335
336    Ok(count > 0)
337}
338
339fn column_exists(conn: &Connection, table: &str, column: &str) -> Result<bool> {
340    let query = format!("PRAGMA table_info({})", table);
341    let mut stmt = conn
342        .prepare(&query)
343        .with_context(|| format!("Failed to read schema for table: {table}"))?;
344
345    let mut rows = stmt
346        .query([])
347        .with_context(|| format!("Failed to query columns for {table}"))?;
348    while let Some(row) = rows.next().context("Failed to iterate table info")? {
349        let col_name: String = row.get(1)?;
350        if col_name == column {
351            return Ok(true);
352        }
353    }
354
355    Ok(false)
356}
357
358/// A simpler representation of storage.json for reading
359#[derive(Debug, Deserialize)]
360#[allow(dead_code)]
361pub struct StorageJson {
362    #[serde(rename = "backupWorkspaces")]
363    pub backup_workspaces: Option<BackupWorkspaces>,
364
365    #[serde(rename = "profileAssociations")]
366    pub profile_associations: Option<ProfileAssociations>,
367}
368
369#[derive(Debug, Deserialize)]
370#[allow(dead_code)]
371pub struct BackupWorkspaces {
372    pub folders: Option<Vec<FolderEntry>>,
373}
374
375#[derive(Debug, Deserialize)]
376#[allow(dead_code)]
377pub struct FolderEntry {
378    #[serde(rename = "folderUri")]
379    pub folder_uri: String,
380}
381
382#[derive(Debug, Deserialize)]
383#[allow(dead_code)]
384pub struct ProfileAssociations {
385    pub workspaces: Option<HashMap<String, String>>,
386}
387
388impl StorageJson {
389    /// Read storage.json from a file
390    #[allow(dead_code)]
391    pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
392        let content = fs::read_to_string(path.as_ref())
393            .with_context(|| format!("Failed to read: {}", path.as_ref().display()))?;
394        serde_json::from_str(&content).context("Failed to parse storage.json")
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use crate::cursor::sqlite_value::Utf8SqlValue;
402    use std::io::Write;
403    use tempfile::NamedTempFile;
404    use tempfile::TempDir;
405
406    #[test]
407    fn test_update_storage_json() {
408        let mut file = NamedTempFile::new().unwrap();
409        write!(
410            file,
411            r#"{{
412    "backupWorkspaces": {{
413        "folders": [
414            {{ "folderUri": "file:///old/path" }},
415            {{ "folderUri": "file:///other/path" }}
416        ]
417    }},
418    "profileAssociations": {{
419        "workspaces": {{
420            "file:///old/path": "__default__profile__"
421        }}
422    }}
423}}"#
424        )
425        .unwrap();
426
427        let modified =
428            update_storage_json(file.path(), "file:///old/path", "file:///new/path", false)
429                .unwrap();
430
431        assert!(modified);
432
433        // Verify changes
434        let content = fs::read_to_string(file.path()).unwrap();
435        assert!(content.contains("file:///new/path"));
436        assert!(!content.contains("file:///old/path"));
437    }
438
439    #[test]
440    fn test_update_global_state_db() {
441        let temp_dir = TempDir::new().unwrap();
442        let db_path = temp_dir.path().join("state.vscdb");
443
444        let conn = Connection::open(&db_path).unwrap();
445        conn.execute(
446            "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)",
447            [],
448        )
449        .unwrap();
450        conn.execute(
451            "CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT)",
452            [],
453        )
454        .unwrap();
455        conn.execute(
456            "INSERT INTO ItemTable(key, value) VALUES (?1, ?2)",
457            (
458                "workspace.key.file:///old/path",
459                "meta:file:///old/path/hash-old",
460            ),
461        )
462        .unwrap();
463        conn.execute(
464            "INSERT INTO cursorDiskKV(key, value) VALUES (?1, ?2)",
465            ("composer:hash-old", "file:///old/path"),
466        )
467        .unwrap();
468        drop(conn);
469
470        let modified = update_global_state_db(
471            &db_path,
472            "file:///old/path",
473            "file:///new/path",
474            "file:///old/path",
475            "file:///new/path",
476            "hash-old",
477            "hash-new",
478            false,
479        )
480        .unwrap();
481
482        assert!(modified);
483
484        let conn = Connection::open(&db_path).unwrap();
485        let item_key: String = conn
486            .query_row(
487                "SELECT key FROM ItemTable WHERE value LIKE '%new/path%'",
488                [],
489                |row| row.get(0),
490            )
491            .unwrap();
492        let item_value: String = conn
493            .query_row(
494                "SELECT value FROM ItemTable WHERE key LIKE '%new/path%'",
495                [],
496                |row| row.get(0),
497            )
498            .unwrap();
499        let disk_key: String = conn
500            .query_row(
501                "SELECT key FROM cursorDiskKV WHERE value LIKE '%new/path%'",
502                [],
503                |row| row.get(0),
504            )
505            .unwrap();
506        let disk_value: String = conn
507            .query_row(
508                "SELECT value FROM cursorDiskKV WHERE key LIKE '%hash-new%'",
509                [],
510                |row| row.get(0),
511            )
512            .unwrap();
513
514        assert_eq!(item_key, "workspace.key.file:///new/path");
515        assert_eq!(item_value, "meta:file:///new/path/hash-new");
516        assert_eq!(disk_key, "composer:hash-new");
517        assert_eq!(disk_value, "file:///new/path");
518    }
519
520    #[test]
521    fn test_update_global_state_db_special_chars() {
522        let temp_dir = TempDir::new().unwrap();
523        let db_path = temp_dir.path().join("state-special.vscdb");
524
525        let conn = Connection::open(&db_path).unwrap();
526        conn.execute(
527            "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)",
528            [],
529        )
530        .unwrap();
531        conn.execute(
532            "INSERT INTO ItemTable(key, value) VALUES (?1, ?2)",
533            (
534                "workspace.key.file:///old%20path%2Fwith%25percent/hash_old",
535                "meta:file:///old%20path%2Fwith%25percent",
536            ),
537        )
538        .unwrap();
539        drop(conn);
540
541        let modified = update_global_state_db(
542            &db_path,
543            "file:///old%20path%2Fwith%25percent",
544            "file:///new%20path%2Fwith%25percent",
545            "file:///old%20path%2Fwith%25percent",
546            "file:///new%20path%2Fwith%25percent",
547            "hash_old",
548            "hash-new",
549            false,
550        )
551        .unwrap();
552
553        assert!(modified);
554
555        let conn = Connection::open(&db_path).unwrap();
556        let item: (String, String) = conn
557            .query_row(
558                "SELECT key, value FROM ItemTable WHERE value LIKE 'meta:file:///new%';",
559                [],
560                |row| Ok((row.get(0).unwrap(), row.get(1).unwrap())),
561            )
562            .unwrap();
563
564        assert_eq!(
565            item.0,
566            "workspace.key.file:///new%20path%2Fwith%25percent/hash-new"
567        );
568        assert_eq!(item.1, "meta:file:///new%20path%2Fwith%25percent");
569    }
570
571    #[test]
572    fn test_update_global_state_db_path_then_uri_update_is_safe() {
573        let temp_dir = TempDir::new().unwrap();
574        let db_path = temp_dir.path().join("state-order.vscdb");
575
576        let conn = Connection::open(&db_path).unwrap();
577        conn.execute(
578            "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)",
579            [],
580        )
581        .unwrap();
582        conn.execute(
583            "INSERT INTO ItemTable(key, value) VALUES (?1, ?2)",
584            (
585                "workspace.key.file:///home/user/project",
586                "file:///home/user/project/hash-old",
587            ),
588        )
589        .unwrap();
590        drop(conn);
591
592        let modified = update_global_state_db(
593            &db_path,
594            "/home/user/project",
595            "/home/user/project-copy",
596            "file:///home/user/project",
597            "file:///home/user/project-copy",
598            "hash-old",
599            "hash-new",
600            false,
601        )
602        .unwrap();
603        assert!(modified);
604
605        let conn = Connection::open(&db_path).unwrap();
606        let item_key: String = conn
607            .query_row("SELECT key FROM ItemTable", [], |row| row.get(0))
608            .unwrap();
609        let item_value: String = conn
610            .query_row("SELECT value FROM ItemTable", [], |row| row.get(0))
611            .unwrap();
612
613        assert_eq!(item_key, "workspace.key.file:///home/user/project-copy");
614        assert_eq!(item_value, "file:///home/user/project-copy/hash-new");
615        assert!(!item_value.contains("project-copy-copy"));
616        assert!(!item_key.contains("project-copy-copy"));
617        assert!(modified);
618    }
619
620    #[test]
621    fn test_update_global_state_db_does_not_modify_workspace_prefix_matches() {
622        let temp_dir = TempDir::new().unwrap();
623        let db_path = temp_dir.path().join("state-prefix.vscdb");
624
625        let conn = Connection::open(&db_path).unwrap();
626        conn.execute(
627            "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)",
628            [],
629        )
630        .unwrap();
631        conn.execute(
632            "INSERT INTO ItemTable(key, value) VALUES (?1, ?2)",
633            (
634                "workspace.key.file:///home/user/project",
635                "meta:file:///home/user/project/hash-old",
636            ),
637        )
638        .unwrap();
639        conn.execute(
640            "INSERT INTO ItemTable(key, value) VALUES (?1, ?2)",
641            (
642                "workspace.key.file:///home/user/projects/foo",
643                "meta:file:///home/user/projects/foo/hash-other",
644            ),
645        )
646        .unwrap();
647        drop(conn);
648
649        let modified = update_global_state_db(
650            &db_path,
651            "/home/user/project",
652            "/home/user/project-copy",
653            "file:///home/user/project",
654            "file:///home/user/project-copy",
655            "hash-old",
656            "hash-new",
657            false,
658        )
659        .unwrap();
660        assert!(modified);
661
662        let conn = Connection::open(&db_path).unwrap();
663        let updated_value: String = conn
664            .query_row(
665                "SELECT value FROM ItemTable WHERE key = 'workspace.key.file:///home/user/project-copy'",
666                [],
667                |row| row.get(0),
668            )
669            .unwrap();
670        let untouched_value: String = conn
671            .query_row(
672                "SELECT value FROM ItemTable WHERE key = 'workspace.key.file:///home/user/projects/foo'",
673                [],
674                |row| row.get(0),
675            )
676            .unwrap();
677
678        assert_eq!(
679            updated_value,
680            "meta:file:///home/user/project-copy/hash-new"
681        );
682        assert_eq!(
683            untouched_value,
684            "meta:file:///home/user/projects/foo/hash-other"
685        );
686    }
687
688    #[test]
689    fn test_update_global_state_db_does_not_corrupt_prefix_values_in_same_cell() {
690        let temp_dir = TempDir::new().unwrap();
691        let db_path = temp_dir.path().join("state-prefix-cell.vscdb");
692
693        let conn = Connection::open(&db_path).unwrap();
694        conn.execute(
695            "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)",
696            [],
697        )
698        .unwrap();
699        conn.execute(
700            "INSERT INTO ItemTable(key, value) VALUES (?1, ?2)",
701            (
702                "workspace.key.file:///home/user/project",
703                r#"{"active":"file:///home/user/project","other":"file:///home/user/projects/foo","cache":"hash-old"}"#,
704            ),
705        )
706        .unwrap();
707        drop(conn);
708
709        let modified = update_global_state_db(
710            &db_path,
711            "/home/user/project",
712            "/home/user/project-copy",
713            "file:///home/user/project",
714            "file:///home/user/project-copy",
715            "hash-old",
716            "hash-new",
717            false,
718        )
719        .unwrap();
720        assert!(modified);
721
722        let conn = Connection::open(&db_path).unwrap();
723        let row_key: String = conn
724            .query_row("SELECT key FROM ItemTable", [], |row| row.get(0))
725            .unwrap();
726        let row_value: String = conn
727            .query_row("SELECT value FROM ItemTable", [], |row| row.get(0))
728            .unwrap();
729
730        assert_eq!(row_key, "workspace.key.file:///home/user/project-copy");
731        assert_eq!(
732            row_value,
733            r#"{"active":"file:///home/user/project-copy","other":"file:///home/user/projects/foo","cache":"hash-new"}"#
734        );
735        assert!(!row_value.contains("project-copy-copy"));
736    }
737
738    #[test]
739    fn test_update_global_state_db_updates_utf8_blob_values_without_changing_type() {
740        let temp_dir = TempDir::new().unwrap();
741        let db_path = temp_dir.path().join("state-blob.vscdb");
742
743        let conn = Connection::open(&db_path).unwrap();
744        conn.execute(
745            "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value BLOB)",
746            [],
747        )
748        .unwrap();
749        conn.execute(
750            "CREATE TABLE cursorDiskKV (key BLOB PRIMARY KEY, value BLOB)",
751            [],
752        )
753        .unwrap();
754        conn.execute(
755            "INSERT INTO ItemTable(key, value) VALUES (?1, ?2)",
756            (
757                "workspace.key.file:///old/path",
758                Vec::from("meta:file:///old/path/hash-old".as_bytes()),
759            ),
760        )
761        .unwrap();
762        conn.execute(
763            "INSERT INTO cursorDiskKV(key, value) VALUES (?1, ?2)",
764            (
765                Vec::from("composer:hash-old".as_bytes()),
766                Vec::from("file:///old/path".as_bytes()),
767            ),
768        )
769        .unwrap();
770        drop(conn);
771
772        let modified = update_global_state_db(
773            &db_path,
774            "/old/path",
775            "/new/path",
776            "file:///old/path",
777            "file:///new/path",
778            "hash-old",
779            "hash-new",
780            false,
781        )
782        .unwrap();
783
784        assert!(modified);
785
786        let conn = Connection::open(&db_path).unwrap();
787        let item_value_type: String = conn
788            .query_row("SELECT typeof(value) FROM ItemTable", [], |row| row.get(0))
789            .unwrap();
790        let disk_key_type: String = conn
791            .query_row("SELECT typeof(key) FROM cursorDiskKV", [], |row| row.get(0))
792            .unwrap();
793        let disk_value_type: String = conn
794            .query_row("SELECT typeof(value) FROM cursorDiskKV", [], |row| {
795                row.get(0)
796            })
797            .unwrap();
798        let item_value = conn
799            .query_row("SELECT value FROM ItemTable", [], |row| {
800                Utf8SqlValue::from_row(row, 0)
801            })
802            .unwrap()
803            .unwrap();
804        let disk_key = conn
805            .query_row("SELECT key FROM cursorDiskKV", [], |row| {
806                Utf8SqlValue::from_row(row, 0)
807            })
808            .unwrap()
809            .unwrap();
810        let disk_value = conn
811            .query_row("SELECT value FROM cursorDiskKV", [], |row| {
812                Utf8SqlValue::from_row(row, 0)
813            })
814            .unwrap()
815            .unwrap();
816
817        assert_eq!(item_value_type, "blob");
818        assert_eq!(disk_key_type, "blob");
819        assert_eq!(disk_value_type, "blob");
820        assert_eq!(item_value.as_str(), "meta:file:///new/path/hash-new");
821        assert_eq!(disk_key.as_str(), "composer:hash-new");
822        assert_eq!(disk_value.as_str(), "file:///new/path");
823    }
824
825    #[test]
826    fn test_update_global_state_db_skips_invalid_utf8_blob_values() {
827        let temp_dir = TempDir::new().unwrap();
828        let db_path = temp_dir.path().join("state-invalid-blob.vscdb");
829
830        let conn = Connection::open(&db_path).unwrap();
831        conn.execute(
832            "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value BLOB)",
833            [],
834        )
835        .unwrap();
836        conn.execute(
837            "INSERT INTO ItemTable(key, value) VALUES (?1, X'80')",
838            ["workspace.key.file:///other/path"],
839        )
840        .unwrap();
841        drop(conn);
842
843        let modified = update_global_state_db(
844            &db_path,
845            "/old/path",
846            "/new/path",
847            "file:///old/path",
848            "file:///new/path",
849            "hash-old",
850            "hash-new",
851            false,
852        )
853        .unwrap();
854
855        assert!(!modified);
856
857        let conn = Connection::open(&db_path).unwrap();
858        let raw: Vec<u8> = conn
859            .query_row("SELECT value FROM ItemTable", [], |row| row.get(0))
860            .unwrap();
861        assert_eq!(raw, vec![0x80]);
862    }
863}