chasm_cli/
workspace.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: Apache-2.0
3//! Workspace discovery and management
4
5use crate::error::{CsmError, Result};
6use crate::models::{ChatSession, SessionWithPath, Workspace, WorkspaceJson};
7use crate::storage::parse_session_json;
8use std::path::{Path, PathBuf};
9use urlencoding::decode;
10
11/// Type alias for workspace info tuple (hash, path, project_path, modified_time)
12pub type WorkspaceInfo = (String, PathBuf, Option<String>, std::time::SystemTime);
13
14/// Get the VS Code workspaceStorage path based on the operating system
15pub fn get_workspace_storage_path() -> Result<PathBuf> {
16    let path = if cfg!(target_os = "windows") {
17        dirs::config_dir().map(|p| p.join("Code").join("User").join("workspaceStorage"))
18    } else if cfg!(target_os = "macos") {
19        dirs::home_dir().map(|p| p.join("Library/Application Support/Code/User/workspaceStorage"))
20    } else {
21        // Linux
22        dirs::home_dir().map(|p| p.join(".config/Code/User/workspaceStorage"))
23    };
24
25    path.ok_or(CsmError::StorageNotFound)
26}
27
28/// Get the VS Code globalStorage path based on the operating system
29pub fn get_global_storage_path() -> Result<PathBuf> {
30    let path = if cfg!(target_os = "windows") {
31        dirs::config_dir().map(|p| p.join("Code").join("User").join("globalStorage"))
32    } else if cfg!(target_os = "macos") {
33        dirs::home_dir().map(|p| p.join("Library/Application Support/Code/User/globalStorage"))
34    } else {
35        // Linux
36        dirs::home_dir().map(|p| p.join(".config/Code/User/globalStorage"))
37    };
38
39    path.ok_or(CsmError::StorageNotFound)
40}
41
42/// Get the path to empty window chat sessions (ALL SESSIONS in VS Code)
43/// These are chat sessions not tied to any specific workspace
44pub fn get_empty_window_sessions_path() -> Result<PathBuf> {
45    let global_storage = get_global_storage_path()?;
46    Ok(global_storage.join("emptyWindowChatSessions"))
47}
48
49/// Decode a workspace folder URI to a path
50pub fn decode_workspace_folder(folder_uri: &str) -> String {
51    let mut folder = folder_uri.to_string();
52
53    // Remove file:// prefix
54    if folder.starts_with("file:///") {
55        folder = folder[8..].to_string();
56    } else if folder.starts_with("file://") {
57        folder = folder[7..].to_string();
58    }
59
60    // URL decode
61    if let Ok(decoded) = decode(&folder) {
62        folder = decoded.into_owned();
63    }
64
65    // On Windows, convert forward slashes to backslashes
66    if cfg!(target_os = "windows") {
67        folder = folder.replace('/', "\\");
68    }
69
70    folder
71}
72
73/// Normalize a path for comparison
74pub fn normalize_path(path: &str) -> String {
75    let path = Path::new(path);
76    if let Ok(canonical) = path.canonicalize() {
77        canonical.to_string_lossy().to_lowercase()
78    } else {
79        // Fallback: lowercase and strip trailing slashes
80        let normalized = path.to_string_lossy().to_lowercase();
81        normalized.trim_end_matches(['/', '\\']).to_string()
82    }
83}
84
85/// Discover all VS Code workspaces
86pub fn discover_workspaces() -> Result<Vec<Workspace>> {
87    let storage_path = get_workspace_storage_path()?;
88
89    if !storage_path.exists() {
90        return Ok(Vec::new());
91    }
92
93    let mut workspaces = Vec::new();
94
95    for entry in std::fs::read_dir(&storage_path)? {
96        let entry = entry?;
97        let workspace_dir = entry.path();
98
99        if !workspace_dir.is_dir() {
100            continue;
101        }
102
103        let workspace_json_path = workspace_dir.join("workspace.json");
104        if !workspace_json_path.exists() {
105            continue;
106        }
107
108        // Parse workspace.json
109        let project_path = match std::fs::read_to_string(&workspace_json_path) {
110            Ok(content) => match serde_json::from_str::<WorkspaceJson>(&content) {
111                Ok(ws_json) => ws_json.folder.map(|f| decode_workspace_folder(&f)),
112                Err(_) => None,
113            },
114            Err(_) => None,
115        };
116
117        let chat_sessions_path = workspace_dir.join("chatSessions");
118        let has_chat_sessions = chat_sessions_path.exists();
119
120        let chat_session_count = if has_chat_sessions {
121            std::fs::read_dir(&chat_sessions_path)
122                .map(|entries| {
123                    entries
124                        .filter_map(|e| e.ok())
125                        .filter(|e| {
126                            e.path()
127                                .extension()
128                                .map(|ext| ext == "json")
129                                .unwrap_or(false)
130                        })
131                        .count()
132                })
133                .unwrap_or(0)
134        } else {
135            0
136        };
137
138        // Get last modified time
139        let last_modified = if has_chat_sessions {
140            std::fs::read_dir(&chat_sessions_path)
141                .ok()
142                .and_then(|entries| {
143                    entries
144                        .filter_map(|e| e.ok())
145                        .filter_map(|e| e.metadata().ok())
146                        .filter_map(|m| m.modified().ok())
147                        .max()
148                })
149                .map(chrono::DateTime::<Utc>::from)
150        } else {
151            None
152        };
153
154        workspaces.push(Workspace {
155            hash: entry.file_name().to_string_lossy().to_string(),
156            project_path,
157            workspace_path: workspace_dir.clone(),
158            chat_sessions_path,
159            chat_session_count,
160            has_chat_sessions,
161            last_modified,
162        });
163    }
164
165    Ok(workspaces)
166}
167
168/// Find a workspace by its hash
169pub fn get_workspace_by_hash(hash: &str) -> Result<Option<Workspace>> {
170    let workspaces = discover_workspaces()?;
171    Ok(workspaces
172        .into_iter()
173        .find(|w| w.hash == hash || w.hash.starts_with(hash)))
174}
175
176/// Find a workspace by project path
177pub fn get_workspace_by_path(project_path: &str) -> Result<Option<Workspace>> {
178    let workspaces = discover_workspaces()?;
179    let target_path = normalize_path(project_path);
180
181    Ok(workspaces.into_iter().find(|w| {
182        w.project_path
183            .as_ref()
184            .map(|p| normalize_path(p) == target_path)
185            .unwrap_or(false)
186    }))
187}
188
189/// Find workspace by path, returning workspace ID, directory, and data.
190/// When multiple workspaces match the same path, returns the most recently modified one.
191pub fn find_workspace_by_path(
192    project_path: &str,
193) -> Result<Option<(String, PathBuf, Option<String>)>> {
194    let storage_path = get_workspace_storage_path()?;
195
196    if !storage_path.exists() {
197        return Ok(None);
198    }
199
200    let target_path = normalize_path(project_path);
201    let mut matches: Vec<(String, PathBuf, Option<String>, std::time::SystemTime)> = Vec::new();
202
203    for entry in std::fs::read_dir(&storage_path)? {
204        let entry = entry?;
205        let workspace_dir = entry.path();
206
207        if !workspace_dir.is_dir() {
208            continue;
209        }
210
211        let workspace_json_path = workspace_dir.join("workspace.json");
212        if !workspace_json_path.exists() {
213            continue;
214        }
215
216        if let Ok(content) = std::fs::read_to_string(&workspace_json_path) {
217            if let Ok(ws_json) = serde_json::from_str::<WorkspaceJson>(&content) {
218                if let Some(folder) = &ws_json.folder {
219                    let folder_path = decode_workspace_folder(folder);
220                    if normalize_path(&folder_path) == target_path {
221                        // Get the most recent modification time from chatSessions or workspace dir
222                        let chat_sessions_dir = workspace_dir.join("chatSessions");
223                        let last_modified = if chat_sessions_dir.exists() {
224                            std::fs::read_dir(&chat_sessions_dir)
225                                .ok()
226                                .and_then(|entries| {
227                                    entries
228                                        .filter_map(|e| e.ok())
229                                        .filter_map(|e| e.metadata().ok())
230                                        .filter_map(|m| m.modified().ok())
231                                        .max()
232                                })
233                                .unwrap_or_else(|| {
234                                    chat_sessions_dir
235                                        .metadata()
236                                        .and_then(|m| m.modified())
237                                        .unwrap_or(std::time::UNIX_EPOCH)
238                                })
239                        } else {
240                            workspace_dir
241                                .metadata()
242                                .and_then(|m| m.modified())
243                                .unwrap_or(std::time::UNIX_EPOCH)
244                        };
245
246                        matches.push((
247                            entry.file_name().to_string_lossy().to_string(),
248                            workspace_dir,
249                            Some(folder_path),
250                            last_modified,
251                        ));
252                    }
253                }
254            }
255        }
256    }
257
258    // Sort by last modified (newest first) and return the most recent
259    matches.sort_by(|a, b| b.3.cmp(&a.3));
260
261    Ok(matches
262        .into_iter()
263        .next()
264        .map(|(id, path, folder, _)| (id, path, folder)))
265}
266
267/// Find all workspaces for a project (by name matching)
268pub fn find_all_workspaces_for_project(project_name: &str) -> Result<Vec<WorkspaceInfo>> {
269    let storage_path = get_workspace_storage_path()?;
270
271    if !storage_path.exists() {
272        return Ok(Vec::new());
273    }
274
275    let project_name_lower = project_name.to_lowercase();
276    let mut workspaces = Vec::new();
277
278    for entry in std::fs::read_dir(&storage_path)? {
279        let entry = entry?;
280        let workspace_dir = entry.path();
281
282        if !workspace_dir.is_dir() {
283            continue;
284        }
285
286        let workspace_json_path = workspace_dir.join("workspace.json");
287        if !workspace_json_path.exists() {
288            continue;
289        }
290
291        if let Ok(content) = std::fs::read_to_string(&workspace_json_path) {
292            if let Ok(ws_json) = serde_json::from_str::<WorkspaceJson>(&content) {
293                if let Some(folder) = &ws_json.folder {
294                    let folder_path = decode_workspace_folder(folder);
295
296                    if folder_path.to_lowercase().contains(&project_name_lower) {
297                        let chat_sessions_dir = workspace_dir.join("chatSessions");
298
299                        let last_modified = if chat_sessions_dir.exists() {
300                            std::fs::read_dir(&chat_sessions_dir)
301                                .ok()
302                                .and_then(|entries| {
303                                    entries
304                                        .filter_map(|e| e.ok())
305                                        .filter_map(|e| e.metadata().ok())
306                                        .filter_map(|m| m.modified().ok())
307                                        .max()
308                                })
309                                .unwrap_or_else(|| {
310                                    chat_sessions_dir
311                                        .metadata()
312                                        .and_then(|m| m.modified())
313                                        .unwrap_or(std::time::UNIX_EPOCH)
314                                })
315                        } else {
316                            workspace_dir
317                                .metadata()
318                                .and_then(|m| m.modified())
319                                .unwrap_or(std::time::UNIX_EPOCH)
320                        };
321
322                        workspaces.push((
323                            entry.file_name().to_string_lossy().to_string(),
324                            workspace_dir,
325                            Some(folder_path),
326                            last_modified,
327                        ));
328                    }
329                }
330            }
331        }
332    }
333
334    // Sort by last modified (newest first)
335    workspaces.sort_by(|a, b| b.3.cmp(&a.3));
336
337    Ok(workspaces)
338}
339
340/// Get all chat sessions from a workspace directory
341pub fn get_chat_sessions_from_workspace(workspace_dir: &Path) -> Result<Vec<SessionWithPath>> {
342    let chat_sessions_dir = workspace_dir.join("chatSessions");
343
344    if !chat_sessions_dir.exists() {
345        return Ok(Vec::new());
346    }
347
348    let mut sessions = Vec::new();
349
350    for entry in std::fs::read_dir(&chat_sessions_dir)? {
351        let entry = entry?;
352        let path = entry.path();
353
354        if path.extension().map(|e| e == "json").unwrap_or(false) {
355            if let Ok(content) = std::fs::read_to_string(&path) {
356                if let Ok(session) = parse_session_json(&content) {
357                    sessions.push(SessionWithPath { path, session });
358                }
359            }
360        }
361    }
362
363    Ok(sessions)
364}
365
366use chrono::Utc;