chasm_cli/
storage.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: Apache-2.0
3//! VS Code storage (SQLite database) operations
4
5use crate::error::{CsmError, Result};
6use crate::models::{ChatSession, ChatSessionIndex, ChatSessionIndexEntry};
7use crate::workspace::{get_empty_window_sessions_path, get_workspace_storage_path};
8use regex::Regex;
9use rusqlite::Connection;
10use std::path::{Path, PathBuf};
11use sysinfo::System;
12
13/// Sanitize JSON content by replacing lone surrogates with replacement character.
14/// VS Code sometimes writes invalid JSON with lone Unicode surrogates (e.g., \udde0).
15fn sanitize_json_unicode(content: &str) -> String {
16    // Rust regex doesn't support lookahead/lookbehind, so we collect all Unicode escapes
17    // and check if they form valid surrogate pairs
18    let re = Regex::new(r"\\u[0-9a-fA-F]{4}").unwrap();
19    let mut result = content.to_string();
20
21    // Collect all matches with their positions
22    let matches: Vec<_> = re
23        .find_iter(content)
24        .map(|m| (m.start(), m.end(), m.as_str().to_string()))
25        .collect();
26
27    // Process in reverse to maintain correct positions
28    for i in (0..matches.len()).rev() {
29        let (start, end, escape) = &matches[i];
30        let hex = &escape[2..];
31        if let Ok(code) = u16::from_str_radix(hex, 16) {
32            // Check if it's a high surrogate (D800-DBFF)
33            if (0xD800..=0xDBFF).contains(&code) {
34                // Check if followed by low surrogate
35                let has_low = matches
36                    .get(i + 1)
37                    .is_some_and(|(next_start, _, next_escape)| {
38                        *next_start == *end && {
39                            let next_hex = &next_escape[2..];
40                            u16::from_str_radix(next_hex, 16)
41                                .map(|c| (0xDC00..=0xDFFF).contains(&c))
42                                .unwrap_or(false)
43                        }
44                    });
45                if !has_low {
46                    result.replace_range(*start..*end, r"\uFFFD");
47                }
48            }
49            // Check if it's a low surrogate (DC00-DFFF)
50            else if (0xDC00..=0xDFFF).contains(&code) {
51                // Check if preceded by high surrogate
52                let has_high = i > 0
53                    && matches
54                        .get(i - 1)
55                        .is_some_and(|(_, prev_end, prev_escape)| {
56                            *prev_end == *start && {
57                                let prev_hex = &prev_escape[2..];
58                                u16::from_str_radix(prev_hex, 16)
59                                    .map(|c| (0xD800..=0xDBFF).contains(&c))
60                                    .unwrap_or(false)
61                            }
62                        });
63                if !has_high {
64                    result.replace_range(*start..*end, r"\uFFFD");
65                }
66            }
67        }
68    }
69
70    result
71}
72
73/// Try to parse JSON, sanitizing invalid Unicode if needed
74pub fn parse_session_json(content: &str) -> std::result::Result<ChatSession, serde_json::Error> {
75    match serde_json::from_str::<ChatSession>(content) {
76        Ok(session) => Ok(session),
77        Err(e) => {
78            // If parsing fails due to Unicode issue, try sanitizing
79            if e.to_string().contains("surrogate") || e.to_string().contains("escape") {
80                let sanitized = sanitize_json_unicode(content);
81                serde_json::from_str::<ChatSession>(&sanitized)
82            } else {
83                Err(e)
84            }
85        }
86    }
87}
88
89/// Get the path to the workspace storage database
90pub fn get_workspace_storage_db(workspace_id: &str) -> Result<PathBuf> {
91    let storage_path = get_workspace_storage_path()?;
92    Ok(storage_path.join(workspace_id).join("state.vscdb"))
93}
94
95/// Read the chat session index from VS Code storage
96pub fn read_chat_session_index(db_path: &Path) -> Result<ChatSessionIndex> {
97    let conn = Connection::open(db_path)?;
98
99    let result: std::result::Result<String, rusqlite::Error> = conn.query_row(
100        "SELECT value FROM ItemTable WHERE key = ?",
101        ["chat.ChatSessionStore.index"],
102        |row| row.get(0),
103    );
104
105    match result {
106        Ok(json_str) => serde_json::from_str(&json_str)
107            .map_err(|e| CsmError::InvalidSessionFormat(e.to_string())),
108        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(ChatSessionIndex::default()),
109        Err(e) => Err(CsmError::SqliteError(e)),
110    }
111}
112
113/// Write the chat session index to VS Code storage
114pub fn write_chat_session_index(db_path: &Path, index: &ChatSessionIndex) -> Result<()> {
115    let conn = Connection::open(db_path)?;
116    let json_str = serde_json::to_string(index)?;
117
118    // Check if the key exists
119    let exists: bool = conn.query_row(
120        "SELECT COUNT(*) > 0 FROM ItemTable WHERE key = ?",
121        ["chat.ChatSessionStore.index"],
122        |row| row.get(0),
123    )?;
124
125    if exists {
126        conn.execute(
127            "UPDATE ItemTable SET value = ? WHERE key = ?",
128            [&json_str, "chat.ChatSessionStore.index"],
129        )?;
130    } else {
131        conn.execute(
132            "INSERT INTO ItemTable (key, value) VALUES (?, ?)",
133            ["chat.ChatSessionStore.index", &json_str],
134        )?;
135    }
136
137    Ok(())
138}
139
140/// Add a session to the VS Code index
141pub fn add_session_to_index(
142    db_path: &Path,
143    session_id: &str,
144    title: &str,
145    last_message_date_ms: i64,
146    is_imported: bool,
147    initial_location: &str,
148    is_empty: bool,
149) -> Result<()> {
150    let mut index = read_chat_session_index(db_path)?;
151
152    index.entries.insert(
153        session_id.to_string(),
154        ChatSessionIndexEntry {
155            session_id: session_id.to_string(),
156            title: title.to_string(),
157            last_message_date: last_message_date_ms,
158            is_imported,
159            initial_location: initial_location.to_string(),
160            is_empty,
161        },
162    );
163
164    write_chat_session_index(db_path, &index)
165}
166
167/// Remove a session from the VS Code index
168#[allow(dead_code)]
169pub fn remove_session_from_index(db_path: &Path, session_id: &str) -> Result<bool> {
170    let mut index = read_chat_session_index(db_path)?;
171    let removed = index.entries.remove(session_id).is_some();
172    if removed {
173        write_chat_session_index(db_path, &index)?;
174    }
175    Ok(removed)
176}
177
178/// Sync the VS Code index with sessions on disk (remove stale entries, add missing ones)
179pub fn sync_session_index(
180    workspace_id: &str,
181    chat_sessions_dir: &Path,
182    force: bool,
183) -> Result<(usize, usize)> {
184    let db_path = get_workspace_storage_db(workspace_id)?;
185
186    if !db_path.exists() {
187        return Err(CsmError::WorkspaceNotFound(format!(
188            "Database not found: {}",
189            db_path.display()
190        )));
191    }
192
193    // Check if VS Code is running
194    if !force && is_vscode_running() {
195        return Err(CsmError::VSCodeRunning);
196    }
197
198    // Get current index
199    let mut index = read_chat_session_index(&db_path)?;
200
201    // Get session files on disk
202    let mut files_on_disk: std::collections::HashSet<String> = std::collections::HashSet::new();
203    if chat_sessions_dir.exists() {
204        for entry in std::fs::read_dir(chat_sessions_dir)? {
205            let entry = entry?;
206            let path = entry.path();
207            if path.extension().map(|e| e == "json").unwrap_or(false) {
208                if let Some(stem) = path.file_stem() {
209                    files_on_disk.insert(stem.to_string_lossy().to_string());
210                }
211            }
212        }
213    }
214
215    // Remove stale entries (in index but not on disk)
216    let stale_ids: Vec<String> = index
217        .entries
218        .keys()
219        .filter(|id| !files_on_disk.contains(*id))
220        .cloned()
221        .collect();
222
223    let removed = stale_ids.len();
224    for id in &stale_ids {
225        index.entries.remove(id);
226    }
227
228    // Add/update sessions from disk
229    let mut added = 0;
230    for entry in std::fs::read_dir(chat_sessions_dir)? {
231        let entry = entry?;
232        let path = entry.path();
233
234        if path.extension().map(|e| e == "json").unwrap_or(false) {
235            if let Ok(content) = std::fs::read_to_string(&path) {
236                if let Ok(session) = parse_session_json(&content) {
237                    let session_id = session.session_id.clone().unwrap_or_else(|| {
238                        path.file_stem()
239                            .map(|s| s.to_string_lossy().to_string())
240                            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
241                    });
242
243                    let title = session.title();
244                    let is_empty = session.is_empty();
245                    let last_message_date = session.last_message_date;
246                    let initial_location = session.initial_location.clone();
247
248                    index.entries.insert(
249                        session_id.clone(),
250                        ChatSessionIndexEntry {
251                            session_id,
252                            title,
253                            last_message_date,
254                            is_imported: session.is_imported,
255                            initial_location,
256                            is_empty,
257                        },
258                    );
259                    added += 1;
260                }
261            }
262        }
263    }
264
265    // Write the synced index
266    write_chat_session_index(&db_path, &index)?;
267
268    Ok((added, removed))
269}
270
271/// Register all sessions from a directory into the VS Code index
272pub fn register_all_sessions_from_directory(
273    workspace_id: &str,
274    chat_sessions_dir: &Path,
275    force: bool,
276) -> Result<usize> {
277    let db_path = get_workspace_storage_db(workspace_id)?;
278
279    if !db_path.exists() {
280        return Err(CsmError::WorkspaceNotFound(format!(
281            "Database not found: {}",
282            db_path.display()
283        )));
284    }
285
286    // Check if VS Code is running
287    if !force && is_vscode_running() {
288        return Err(CsmError::VSCodeRunning);
289    }
290
291    // Use sync to ensure index matches disk
292    let (added, removed) = sync_session_index(workspace_id, chat_sessions_dir, force)?;
293
294    // Print individual session info
295    for entry in std::fs::read_dir(chat_sessions_dir)? {
296        let entry = entry?;
297        let path = entry.path();
298
299        if path.extension().map(|e| e == "json").unwrap_or(false) {
300            if let Ok(content) = std::fs::read_to_string(&path) {
301                if let Ok(session) = parse_session_json(&content) {
302                    let session_id = session.session_id.clone().unwrap_or_else(|| {
303                        path.file_stem()
304                            .map(|s| s.to_string_lossy().to_string())
305                            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
306                    });
307
308                    let title = session.title();
309
310                    println!(
311                        "[OK] Registered: {} ({}...)",
312                        title,
313                        &session_id[..12.min(session_id.len())]
314                    );
315                }
316            }
317        }
318    }
319
320    if removed > 0 {
321        println!("[OK] Removed {} stale index entries", removed);
322    }
323
324    Ok(added)
325}
326
327/// Check if VS Code is currently running
328pub fn is_vscode_running() -> bool {
329    let mut sys = System::new();
330    sys.refresh_processes();
331
332    for process in sys.processes().values() {
333        let name = process.name().to_lowercase();
334        if name.contains("code") && !name.contains("codec") {
335            return true;
336        }
337    }
338
339    false
340}
341
342/// Backup workspace sessions to a timestamped directory
343pub fn backup_workspace_sessions(workspace_dir: &Path) -> Result<Option<PathBuf>> {
344    let chat_sessions_dir = workspace_dir.join("chatSessions");
345
346    if !chat_sessions_dir.exists() {
347        return Ok(None);
348    }
349
350    let timestamp = std::time::SystemTime::now()
351        .duration_since(std::time::UNIX_EPOCH)
352        .unwrap()
353        .as_secs();
354
355    let backup_dir = workspace_dir.join(format!("chatSessions-backup-{}", timestamp));
356
357    // Copy directory recursively
358    copy_dir_all(&chat_sessions_dir, &backup_dir)?;
359
360    Ok(Some(backup_dir))
361}
362
363/// Recursively copy a directory
364fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
365    std::fs::create_dir_all(dst)?;
366
367    for entry in std::fs::read_dir(src)? {
368        let entry = entry?;
369        let src_path = entry.path();
370        let dst_path = dst.join(entry.file_name());
371
372        if src_path.is_dir() {
373            copy_dir_all(&src_path, &dst_path)?;
374        } else {
375            std::fs::copy(&src_path, &dst_path)?;
376        }
377    }
378
379    Ok(())
380}
381
382// =============================================================================
383// Empty Window Sessions (ALL SESSIONS)
384// =============================================================================
385
386/// Read all empty window chat sessions (not tied to any workspace)
387/// These appear in VS Code's "ALL SESSIONS" panel
388pub fn read_empty_window_sessions() -> Result<Vec<ChatSession>> {
389    let sessions_path = get_empty_window_sessions_path()?;
390
391    if !sessions_path.exists() {
392        return Ok(Vec::new());
393    }
394
395    let mut sessions = Vec::new();
396
397    for entry in std::fs::read_dir(&sessions_path)? {
398        let entry = entry?;
399        let path = entry.path();
400
401        if path.extension().is_some_and(|e| e == "json") {
402            if let Ok(content) = std::fs::read_to_string(&path) {
403                if let Ok(session) = parse_session_json(&content) {
404                    sessions.push(session);
405                }
406            }
407        }
408    }
409
410    // Sort by last message date (most recent first)
411    sessions.sort_by(|a, b| b.last_message_date.cmp(&a.last_message_date));
412
413    Ok(sessions)
414}
415
416/// Get a specific empty window session by ID
417#[allow(dead_code)]
418pub fn get_empty_window_session(session_id: &str) -> Result<Option<ChatSession>> {
419    let sessions_path = get_empty_window_sessions_path()?;
420    let session_path = sessions_path.join(format!("{}.json", session_id));
421
422    if !session_path.exists() {
423        return Ok(None);
424    }
425
426    let content = std::fs::read_to_string(&session_path)?;
427    let session: ChatSession = serde_json::from_str(&content)
428        .map_err(|e| CsmError::InvalidSessionFormat(e.to_string()))?;
429
430    Ok(Some(session))
431}
432
433/// Write an empty window session
434#[allow(dead_code)]
435pub fn write_empty_window_session(session: &ChatSession) -> Result<PathBuf> {
436    let sessions_path = get_empty_window_sessions_path()?;
437
438    // Create directory if it doesn't exist
439    std::fs::create_dir_all(&sessions_path)?;
440
441    let session_id = session.session_id.as_deref().unwrap_or("unknown");
442    let session_path = sessions_path.join(format!("{}.json", session_id));
443    let content = serde_json::to_string_pretty(session)?;
444    std::fs::write(&session_path, content)?;
445
446    Ok(session_path)
447}
448
449/// Delete an empty window session
450#[allow(dead_code)]
451pub fn delete_empty_window_session(session_id: &str) -> Result<bool> {
452    let sessions_path = get_empty_window_sessions_path()?;
453    let session_path = sessions_path.join(format!("{}.json", session_id));
454
455    if session_path.exists() {
456        std::fs::remove_file(&session_path)?;
457        Ok(true)
458    } else {
459        Ok(false)
460    }
461}
462
463/// Count empty window sessions
464pub fn count_empty_window_sessions() -> Result<usize> {
465    let sessions_path = get_empty_window_sessions_path()?;
466
467    if !sessions_path.exists() {
468        return Ok(0);
469    }
470
471    let count = std::fs::read_dir(&sessions_path)?
472        .filter_map(|e| e.ok())
473        .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
474        .count();
475
476    Ok(count)
477}