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 rusqlite::Connection;
9use std::path::{Path, PathBuf};
10use sysinfo::System;
11
12/// Get the path to the workspace storage database
13pub fn get_workspace_storage_db(workspace_id: &str) -> Result<PathBuf> {
14    let storage_path = get_workspace_storage_path()?;
15    Ok(storage_path.join(workspace_id).join("state.vscdb"))
16}
17
18/// Read the chat session index from VS Code storage
19pub fn read_chat_session_index(db_path: &Path) -> Result<ChatSessionIndex> {
20    let conn = Connection::open(db_path)?;
21
22    let result: std::result::Result<String, rusqlite::Error> = conn.query_row(
23        "SELECT value FROM ItemTable WHERE key = ?",
24        ["chat.ChatSessionStore.index"],
25        |row| row.get(0),
26    );
27
28    match result {
29        Ok(json_str) => serde_json::from_str(&json_str)
30            .map_err(|e| CsmError::InvalidSessionFormat(e.to_string())),
31        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(ChatSessionIndex::default()),
32        Err(e) => Err(CsmError::SqliteError(e)),
33    }
34}
35
36/// Write the chat session index to VS Code storage
37pub fn write_chat_session_index(db_path: &Path, index: &ChatSessionIndex) -> Result<()> {
38    let conn = Connection::open(db_path)?;
39    let json_str = serde_json::to_string(index)?;
40
41    // Check if the key exists
42    let exists: bool = conn.query_row(
43        "SELECT COUNT(*) > 0 FROM ItemTable WHERE key = ?",
44        ["chat.ChatSessionStore.index"],
45        |row| row.get(0),
46    )?;
47
48    if exists {
49        conn.execute(
50            "UPDATE ItemTable SET value = ? WHERE key = ?",
51            [&json_str, "chat.ChatSessionStore.index"],
52        )?;
53    } else {
54        conn.execute(
55            "INSERT INTO ItemTable (key, value) VALUES (?, ?)",
56            ["chat.ChatSessionStore.index", &json_str],
57        )?;
58    }
59
60    Ok(())
61}
62
63/// Add a session to the VS Code index
64pub fn add_session_to_index(
65    db_path: &Path,
66    session_id: &str,
67    title: &str,
68    last_message_date_ms: i64,
69    is_imported: bool,
70    initial_location: &str,
71    is_empty: bool,
72) -> Result<()> {
73    let mut index = read_chat_session_index(db_path)?;
74
75    index.entries.insert(
76        session_id.to_string(),
77        ChatSessionIndexEntry {
78            session_id: session_id.to_string(),
79            title: title.to_string(),
80            last_message_date: last_message_date_ms,
81            is_imported,
82            initial_location: initial_location.to_string(),
83            is_empty,
84        },
85    );
86
87    write_chat_session_index(db_path, &index)
88}
89
90/// Remove a session from the VS Code index
91pub fn remove_session_from_index(db_path: &Path, session_id: &str) -> Result<bool> {
92    let mut index = read_chat_session_index(db_path)?;
93    let removed = index.entries.remove(session_id).is_some();
94    if removed {
95        write_chat_session_index(db_path, &index)?;
96    }
97    Ok(removed)
98}
99
100/// Sync the VS Code index with sessions on disk (remove stale entries, add missing ones)
101pub fn sync_session_index(
102    workspace_id: &str,
103    chat_sessions_dir: &Path,
104    force: bool,
105) -> Result<(usize, usize)> {
106    let db_path = get_workspace_storage_db(workspace_id)?;
107
108    if !db_path.exists() {
109        return Err(CsmError::WorkspaceNotFound(format!(
110            "Database not found: {}",
111            db_path.display()
112        )));
113    }
114
115    // Check if VS Code is running
116    if !force && is_vscode_running() {
117        return Err(CsmError::VSCodeRunning);
118    }
119
120    // Get current index
121    let mut index = read_chat_session_index(&db_path)?;
122
123    // Get session files on disk
124    let mut files_on_disk: std::collections::HashSet<String> = std::collections::HashSet::new();
125    if chat_sessions_dir.exists() {
126        for entry in std::fs::read_dir(chat_sessions_dir)? {
127            let entry = entry?;
128            let path = entry.path();
129            if path.extension().map(|e| e == "json").unwrap_or(false) {
130                if let Some(stem) = path.file_stem() {
131                    files_on_disk.insert(stem.to_string_lossy().to_string());
132                }
133            }
134        }
135    }
136
137    // Remove stale entries (in index but not on disk)
138    let stale_ids: Vec<String> = index
139        .entries
140        .keys()
141        .filter(|id| !files_on_disk.contains(*id))
142        .cloned()
143        .collect();
144
145    let removed = stale_ids.len();
146    for id in &stale_ids {
147        index.entries.remove(id);
148    }
149
150    // Add/update sessions from disk
151    let mut added = 0;
152    for entry in std::fs::read_dir(chat_sessions_dir)? {
153        let entry = entry?;
154        let path = entry.path();
155
156        if path.extension().map(|e| e == "json").unwrap_or(false) {
157            if let Ok(content) = std::fs::read_to_string(&path) {
158                if let Ok(session) = serde_json::from_str::<ChatSession>(&content) {
159                    let session_id = session.session_id.clone().unwrap_or_else(|| {
160                        path.file_stem()
161                            .map(|s| s.to_string_lossy().to_string())
162                            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
163                    });
164
165                    let title = session.title();
166                    let is_empty = session.is_empty();
167                    let last_message_date = session.last_message_date;
168                    let initial_location = session.initial_location.clone();
169
170                    index.entries.insert(
171                        session_id.clone(),
172                        ChatSessionIndexEntry {
173                            session_id,
174                            title,
175                            last_message_date,
176                            is_imported: session.is_imported,
177                            initial_location,
178                            is_empty,
179                        },
180                    );
181                    added += 1;
182                }
183            }
184        }
185    }
186
187    // Write the synced index
188    write_chat_session_index(&db_path, &index)?;
189
190    Ok((added, removed))
191}
192
193/// Register all sessions from a directory into the VS Code index
194pub fn register_all_sessions_from_directory(
195    workspace_id: &str,
196    chat_sessions_dir: &Path,
197    force: bool,
198) -> Result<usize> {
199    let db_path = get_workspace_storage_db(workspace_id)?;
200
201    if !db_path.exists() {
202        return Err(CsmError::WorkspaceNotFound(format!(
203            "Database not found: {}",
204            db_path.display()
205        )));
206    }
207
208    // Check if VS Code is running
209    if !force && is_vscode_running() {
210        return Err(CsmError::VSCodeRunning);
211    }
212
213    // Use sync to ensure index matches disk
214    let (added, removed) = sync_session_index(workspace_id, chat_sessions_dir, force)?;
215
216    // Print individual session info
217    for entry in std::fs::read_dir(chat_sessions_dir)? {
218        let entry = entry?;
219        let path = entry.path();
220
221        if path.extension().map(|e| e == "json").unwrap_or(false) {
222            if let Ok(content) = std::fs::read_to_string(&path) {
223                if let Ok(session) = serde_json::from_str::<ChatSession>(&content) {
224                    let session_id = session.session_id.clone().unwrap_or_else(|| {
225                        path.file_stem()
226                            .map(|s| s.to_string_lossy().to_string())
227                            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
228                    });
229
230                    let title = session.title();
231
232                    println!(
233                        "[OK] Registered: {} ({}...)",
234                        title,
235                        &session_id[..12.min(session_id.len())]
236                    );
237                }
238            }
239        }
240    }
241
242    if removed > 0 {
243        println!("[OK] Removed {} stale index entries", removed);
244    }
245
246    Ok(added)
247}
248
249/// Check if VS Code is currently running
250pub fn is_vscode_running() -> bool {
251    let mut sys = System::new();
252    sys.refresh_processes();
253
254    for process in sys.processes().values() {
255        let name = process.name().to_lowercase();
256        if name.contains("code") && !name.contains("codec") {
257            return true;
258        }
259    }
260
261    false
262}
263
264/// Backup workspace sessions to a timestamped directory
265pub fn backup_workspace_sessions(workspace_dir: &Path) -> Result<Option<PathBuf>> {
266    let chat_sessions_dir = workspace_dir.join("chatSessions");
267
268    if !chat_sessions_dir.exists() {
269        return Ok(None);
270    }
271
272    let timestamp = std::time::SystemTime::now()
273        .duration_since(std::time::UNIX_EPOCH)
274        .unwrap()
275        .as_secs();
276
277    let backup_dir = workspace_dir.join(format!("chatSessions-backup-{}", timestamp));
278
279    // Copy directory recursively
280    copy_dir_all(&chat_sessions_dir, &backup_dir)?;
281
282    Ok(Some(backup_dir))
283}
284
285/// Recursively copy a directory
286fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
287    std::fs::create_dir_all(dst)?;
288
289    for entry in std::fs::read_dir(src)? {
290        let entry = entry?;
291        let src_path = entry.path();
292        let dst_path = dst.join(entry.file_name());
293
294        if src_path.is_dir() {
295            copy_dir_all(&src_path, &dst_path)?;
296        } else {
297            std::fs::copy(&src_path, &dst_path)?;
298        }
299    }
300
301    Ok(())
302}
303
304// =============================================================================
305// Empty Window Sessions (ALL SESSIONS)
306// =============================================================================
307
308/// Read all empty window chat sessions (not tied to any workspace)
309/// These appear in VS Code's "ALL SESSIONS" panel
310pub fn read_empty_window_sessions() -> Result<Vec<ChatSession>> {
311    let sessions_path = get_empty_window_sessions_path()?;
312
313    if !sessions_path.exists() {
314        return Ok(Vec::new());
315    }
316
317    let mut sessions = Vec::new();
318
319    for entry in std::fs::read_dir(&sessions_path)? {
320        let entry = entry?;
321        let path = entry.path();
322
323        if path.extension().is_some_and(|e| e == "json") {
324            if let Ok(content) = std::fs::read_to_string(&path) {
325                if let Ok(session) = serde_json::from_str::<ChatSession>(&content) {
326                    sessions.push(session);
327                }
328            }
329        }
330    }
331
332    // Sort by last message date (most recent first)
333    sessions.sort_by(|a, b| b.last_message_date.cmp(&a.last_message_date));
334
335    Ok(sessions)
336}
337
338/// Get a specific empty window session by ID
339#[allow(dead_code)]
340pub fn get_empty_window_session(session_id: &str) -> Result<Option<ChatSession>> {
341    let sessions_path = get_empty_window_sessions_path()?;
342    let session_path = sessions_path.join(format!("{}.json", session_id));
343
344    if !session_path.exists() {
345        return Ok(None);
346    }
347
348    let content = std::fs::read_to_string(&session_path)?;
349    let session: ChatSession = serde_json::from_str(&content)
350        .map_err(|e| CsmError::InvalidSessionFormat(e.to_string()))?;
351
352    Ok(Some(session))
353}
354
355/// Write an empty window session
356#[allow(dead_code)]
357pub fn write_empty_window_session(session: &ChatSession) -> Result<PathBuf> {
358    let sessions_path = get_empty_window_sessions_path()?;
359
360    // Create directory if it doesn't exist
361    std::fs::create_dir_all(&sessions_path)?;
362
363    let session_id = session.session_id.as_deref().unwrap_or("unknown");
364    let session_path = sessions_path.join(format!("{}.json", session_id));
365    let content = serde_json::to_string_pretty(session)?;
366    std::fs::write(&session_path, content)?;
367
368    Ok(session_path)
369}
370
371/// Delete an empty window session
372#[allow(dead_code)]
373pub fn delete_empty_window_session(session_id: &str) -> Result<bool> {
374    let sessions_path = get_empty_window_sessions_path()?;
375    let session_path = sessions_path.join(format!("{}.json", session_id));
376
377    if session_path.exists() {
378        std::fs::remove_file(&session_path)?;
379        Ok(true)
380    } else {
381        Ok(false)
382    }
383}
384
385/// Count empty window sessions
386pub fn count_empty_window_sessions() -> Result<usize> {
387    let sessions_path = get_empty_window_sessions_path()?;
388
389    if !sessions_path.exists() {
390        return Ok(0);
391    }
392
393    let count = std::fs::read_dir(&sessions_path)?
394        .filter_map(|e| e.ok())
395        .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
396        .count();
397
398    Ok(count)
399}