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