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/// Register all sessions from a directory into the VS Code index
91pub fn register_all_sessions_from_directory(
92    workspace_id: &str,
93    chat_sessions_dir: &Path,
94    force: bool,
95) -> Result<usize> {
96    let db_path = get_workspace_storage_db(workspace_id)?;
97
98    if !db_path.exists() {
99        return Err(CsmError::WorkspaceNotFound(format!(
100            "Database not found: {}",
101            db_path.display()
102        )));
103    }
104
105    // Check if VS Code is running
106    if !force && is_vscode_running() {
107        return Err(CsmError::VSCodeRunning);
108    }
109
110    let mut registered = 0;
111
112    for entry in std::fs::read_dir(chat_sessions_dir)? {
113        let entry = entry?;
114        let path = entry.path();
115
116        if path.extension().map(|e| e == "json").unwrap_or(false) {
117            if let Ok(content) = std::fs::read_to_string(&path) {
118                if let Ok(session) = serde_json::from_str::<ChatSession>(&content) {
119                    // Get session ID from the file - use filename (without .json) as ID
120                    let session_id = session.session_id.clone().unwrap_or_else(|| {
121                        path.file_stem()
122                            .map(|s| s.to_string_lossy().to_string())
123                            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
124                    });
125
126                    let title = session.title();
127                    let is_empty = session.is_empty();
128                    let last_message_date = session.last_message_date;
129                    let initial_location = session.initial_location.clone();
130
131                    add_session_to_index(
132                        &db_path,
133                        &session_id,
134                        &title,
135                        last_message_date,
136                        session.is_imported,
137                        &initial_location,
138                        is_empty,
139                    )?;
140
141                    println!(
142                        "[OK] Registered: {} ({}...)",
143                        title,
144                        &session_id[..12.min(session_id.len())]
145                    );
146                    registered += 1;
147                }
148            }
149        }
150    }
151
152    Ok(registered)
153}
154
155/// Check if VS Code is currently running
156pub fn is_vscode_running() -> bool {
157    let mut sys = System::new();
158    sys.refresh_processes();
159
160    for process in sys.processes().values() {
161        let name = process.name().to_lowercase();
162        if name.contains("code") && !name.contains("codec") {
163            return true;
164        }
165    }
166
167    false
168}
169
170/// Backup workspace sessions to a timestamped directory
171pub fn backup_workspace_sessions(workspace_dir: &Path) -> Result<Option<PathBuf>> {
172    let chat_sessions_dir = workspace_dir.join("chatSessions");
173
174    if !chat_sessions_dir.exists() {
175        return Ok(None);
176    }
177
178    let timestamp = std::time::SystemTime::now()
179        .duration_since(std::time::UNIX_EPOCH)
180        .unwrap()
181        .as_secs();
182
183    let backup_dir = workspace_dir.join(format!("chatSessions-backup-{}", timestamp));
184
185    // Copy directory recursively
186    copy_dir_all(&chat_sessions_dir, &backup_dir)?;
187
188    Ok(Some(backup_dir))
189}
190
191/// Recursively copy a directory
192fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
193    std::fs::create_dir_all(dst)?;
194
195    for entry in std::fs::read_dir(src)? {
196        let entry = entry?;
197        let src_path = entry.path();
198        let dst_path = dst.join(entry.file_name());
199
200        if src_path.is_dir() {
201            copy_dir_all(&src_path, &dst_path)?;
202        } else {
203            std::fs::copy(&src_path, &dst_path)?;
204        }
205    }
206
207    Ok(())
208}
209
210// =============================================================================
211// Empty Window Sessions (ALL SESSIONS)
212// =============================================================================
213
214/// Read all empty window chat sessions (not tied to any workspace)
215/// These appear in VS Code's "ALL SESSIONS" panel
216pub fn read_empty_window_sessions() -> Result<Vec<ChatSession>> {
217    let sessions_path = get_empty_window_sessions_path()?;
218
219    if !sessions_path.exists() {
220        return Ok(Vec::new());
221    }
222
223    let mut sessions = Vec::new();
224
225    for entry in std::fs::read_dir(&sessions_path)? {
226        let entry = entry?;
227        let path = entry.path();
228
229        if path.extension().is_some_and(|e| e == "json") {
230            if let Ok(content) = std::fs::read_to_string(&path) {
231                if let Ok(session) = serde_json::from_str::<ChatSession>(&content) {
232                    sessions.push(session);
233                }
234            }
235        }
236    }
237
238    // Sort by last message date (most recent first)
239    sessions.sort_by(|a, b| b.last_message_date.cmp(&a.last_message_date));
240
241    Ok(sessions)
242}
243
244/// Get a specific empty window session by ID
245#[allow(dead_code)]
246pub fn get_empty_window_session(session_id: &str) -> Result<Option<ChatSession>> {
247    let sessions_path = get_empty_window_sessions_path()?;
248    let session_path = sessions_path.join(format!("{}.json", session_id));
249
250    if !session_path.exists() {
251        return Ok(None);
252    }
253
254    let content = std::fs::read_to_string(&session_path)?;
255    let session: ChatSession = serde_json::from_str(&content)
256        .map_err(|e| CsmError::InvalidSessionFormat(e.to_string()))?;
257
258    Ok(Some(session))
259}
260
261/// Write an empty window session
262#[allow(dead_code)]
263pub fn write_empty_window_session(session: &ChatSession) -> Result<PathBuf> {
264    let sessions_path = get_empty_window_sessions_path()?;
265
266    // Create directory if it doesn't exist
267    std::fs::create_dir_all(&sessions_path)?;
268
269    let session_id = session.session_id.as_deref().unwrap_or("unknown");
270    let session_path = sessions_path.join(format!("{}.json", session_id));
271    let content = serde_json::to_string_pretty(session)?;
272    std::fs::write(&session_path, content)?;
273
274    Ok(session_path)
275}
276
277/// Delete an empty window session
278#[allow(dead_code)]
279pub fn delete_empty_window_session(session_id: &str) -> Result<bool> {
280    let sessions_path = get_empty_window_sessions_path()?;
281    let session_path = sessions_path.join(format!("{}.json", session_id));
282
283    if session_path.exists() {
284        std::fs::remove_file(&session_path)?;
285        Ok(true)
286    } else {
287        Ok(false)
288    }
289}
290
291/// Count empty window sessions
292pub fn count_empty_window_sessions() -> Result<usize> {
293    let sessions_path = get_empty_window_sessions_path()?;
294
295    if !sessions_path.exists() {
296        return Ok(0);
297    }
298
299    let count = std::fs::read_dir(&sessions_path)?
300        .filter_map(|e| e.ok())
301        .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
302        .count();
303
304    Ok(count)
305}