chasm_cli/commands/
register.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: Apache-2.0
3//! Register commands - Add sessions to VS Code's session index
4//!
5//! VS Code only displays sessions that are registered in the `chat.ChatSessionStore.index`
6//! stored in `state.vscdb`. Sessions can exist on disk but be invisible to VS Code if
7//! they're not in this index. These commands help register orphaned sessions.
8
9use anyhow::Result;
10use colored::*;
11use std::collections::HashSet;
12use std::path::PathBuf;
13
14use crate::error::CsmError;
15use crate::models::ChatSession;
16use crate::storage::{
17    add_session_to_index, get_workspace_storage_db, is_vscode_running, read_chat_session_index,
18    register_all_sessions_from_directory,
19};
20use crate::workspace::find_workspace_by_path;
21
22/// Register all sessions from a workspace into VS Code's index
23pub fn register_all(project_path: Option<&str>, merge: bool, force: bool) -> Result<()> {
24    let path = project_path
25        .map(PathBuf::from)
26        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
27
28    if merge {
29        println!(
30            "{} Merging and registering all sessions for: {}",
31            "[CSM]".cyan().bold(),
32            path.display()
33        );
34
35        // Use the existing merge functionality
36        let path_str = path.to_string_lossy().to_string();
37        return crate::commands::history_merge(
38            Some(&path_str),
39            None,  // title
40            force, // force
41            false, // no_backup
42        );
43    }
44
45    println!(
46        "{} Registering all sessions for: {}",
47        "[CSM]".cyan().bold(),
48        path.display()
49    );
50
51    // Find the workspace
52    let path_str = path.to_string_lossy().to_string();
53    let (ws_id, ws_path, _folder) = find_workspace_by_path(&path_str)?
54        .ok_or_else(|| CsmError::WorkspaceNotFound(path.display().to_string()))?;
55
56    let chat_sessions_dir = ws_path.join("chatSessions");
57
58    if !chat_sessions_dir.exists() {
59        println!(
60            "{} No chatSessions directory found at: {}",
61            "[!]".yellow(),
62            chat_sessions_dir.display()
63        );
64        return Ok(());
65    }
66
67    // Check if VS Code is running
68    if !force && is_vscode_running() {
69        println!(
70            "{} VS Code is running. Use {} to register anyway.",
71            "[!]".yellow(),
72            "--force".cyan()
73        );
74        println!("   Note: VS Code uses WAL mode so this is generally safe.");
75        return Err(CsmError::VSCodeRunning.into());
76    }
77
78    // Count sessions on disk
79    let sessions_on_disk = count_sessions_in_directory(&chat_sessions_dir)?;
80    println!(
81        "   Found {} session files on disk",
82        sessions_on_disk.to_string().green()
83    );
84
85    // Register all sessions
86    let registered = register_all_sessions_from_directory(&ws_id, &chat_sessions_dir, force)?;
87
88    println!(
89        "\n{} Registered {} sessions in VS Code's index",
90        "[OK]".green().bold(),
91        registered.to_string().cyan()
92    );
93
94    // Always show reload instructions since VS Code caches the index
95    println!(
96        "\n{} VS Code caches the session index in memory.",
97        "[!]".yellow()
98    );
99    println!("   To see the new sessions, do one of the following:");
100    println!(
101        "   * Run: {} (if CSM extension is installed)",
102        "code --command csm.reloadAndShowChats".cyan()
103    );
104    println!(
105        "   * Or press {} in VS Code and run {}",
106        "Ctrl+Shift+P".cyan(),
107        "Developer: Reload Window".cyan()
108    );
109    println!("   * Or restart VS Code");
110
111    Ok(())
112}
113
114/// Register specific sessions by ID or title
115pub fn register_sessions(
116    ids: &[String],
117    titles: Option<&[String]>,
118    project_path: Option<&str>,
119    force: bool,
120) -> Result<()> {
121    let path = project_path
122        .map(PathBuf::from)
123        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
124
125    // Find the workspace
126    let path_str = path.to_string_lossy().to_string();
127    let (ws_id, ws_path, _folder) = find_workspace_by_path(&path_str)?
128        .ok_or_else(|| CsmError::WorkspaceNotFound(path.display().to_string()))?;
129
130    let chat_sessions_dir = ws_path.join("chatSessions");
131
132    // Check if VS Code is running
133    if !force && is_vscode_running() {
134        println!(
135            "{} VS Code is running. Use {} to register anyway.",
136            "[!]".yellow(),
137            "--force".cyan()
138        );
139        return Err(CsmError::VSCodeRunning.into());
140    }
141
142    // Get the database path
143    let db_path = get_workspace_storage_db(&ws_id)?;
144
145    let mut registered_count = 0;
146
147    if let Some(titles) = titles {
148        // Register by title
149        println!(
150            "{} Registering {} sessions by title:",
151            "[CSM]".cyan().bold(),
152            titles.len()
153        );
154
155        let sessions = find_sessions_by_titles(&chat_sessions_dir, titles)?;
156
157        for (session, session_path) in sessions {
158            let session_id = session.session_id.clone().unwrap_or_else(|| {
159                session_path
160                    .file_stem()
161                    .map(|s| s.to_string_lossy().to_string())
162                    .unwrap_or_default()
163            });
164            let title = session.title();
165
166            add_session_to_index(
167                &db_path,
168                &session_id,
169                &title,
170                session.last_message_date,
171                session.is_imported,
172                &session.initial_location,
173                session.is_empty(),
174            )?;
175
176            let id_display = if session_id.len() > 12 {
177                &session_id[..12]
178            } else {
179                &session_id
180            };
181            println!(
182                "   {} {} (\"{}\")",
183                "[OK]".green(),
184                id_display.cyan(),
185                title.yellow()
186            );
187            registered_count += 1;
188        }
189    } else {
190        // Register by ID (default)
191        println!(
192            "{} Registering {} sessions by ID:",
193            "[CSM]".cyan().bold(),
194            ids.len()
195        );
196
197        for session_id in ids {
198            match find_session_file(&chat_sessions_dir, session_id) {
199                Ok(session_file) => {
200                    let content = std::fs::read_to_string(&session_file)?;
201                    let session: ChatSession = serde_json::from_str(&content)?;
202
203                    let title = session.title();
204                    let actual_session_id = session
205                        .session_id
206                        .clone()
207                        .unwrap_or_else(|| session_id.to_string());
208
209                    add_session_to_index(
210                        &db_path,
211                        &actual_session_id,
212                        &title,
213                        session.last_message_date,
214                        session.is_imported,
215                        &session.initial_location,
216                        session.is_empty(),
217                    )?;
218
219                    let id_display = if actual_session_id.len() > 12 {
220                        &actual_session_id[..12]
221                    } else {
222                        &actual_session_id
223                    };
224                    println!(
225                        "   {} {} (\"{}\")",
226                        "[OK]".green(),
227                        id_display.cyan(),
228                        title.yellow()
229                    );
230                    registered_count += 1;
231                }
232                Err(e) => {
233                    println!(
234                        "   {} {} - {}",
235                        "[ERR]".red(),
236                        session_id.cyan(),
237                        e.to_string().red()
238                    );
239                }
240            }
241        }
242    }
243
244    println!(
245        "\n{} Registered {} sessions in VS Code's index",
246        "[OK]".green().bold(),
247        registered_count.to_string().cyan()
248    );
249
250    if force && is_vscode_running() {
251        println!(
252            "   {} Sessions should appear in VS Code immediately",
253            "->".cyan()
254        );
255    }
256
257    Ok(())
258}
259
260/// List sessions that exist on disk but are not in VS Code's index
261pub fn list_orphaned(project_path: Option<&str>) -> Result<()> {
262    let path = project_path
263        .map(PathBuf::from)
264        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
265
266    println!(
267        "{} Finding orphaned sessions for: {}",
268        "[CSM]".cyan().bold(),
269        path.display()
270    );
271
272    // Find the workspace
273    let path_str = path.to_string_lossy().to_string();
274    let (ws_id, ws_path, _folder) = find_workspace_by_path(&path_str)?
275        .ok_or_else(|| CsmError::WorkspaceNotFound(path.display().to_string()))?;
276
277    let chat_sessions_dir = ws_path.join("chatSessions");
278
279    if !chat_sessions_dir.exists() {
280        println!("{} No chatSessions directory found", "[!]".yellow());
281        return Ok(());
282    }
283
284    // Get sessions currently in the index
285    let db_path = get_workspace_storage_db(&ws_id)?;
286    let index = read_chat_session_index(&db_path)?;
287    let indexed_ids: HashSet<String> = index.entries.keys().cloned().collect();
288
289    println!(
290        "   {} sessions currently in VS Code's index",
291        indexed_ids.len().to_string().cyan()
292    );
293
294    // Find sessions on disk
295    let mut orphaned_sessions = Vec::new();
296
297    for entry in std::fs::read_dir(&chat_sessions_dir)? {
298        let entry = entry?;
299        let path = entry.path();
300
301        if path.extension().map(|e| e == "json").unwrap_or(false) {
302            if let Ok(content) = std::fs::read_to_string(&path) {
303                if let Ok(session) = serde_json::from_str::<ChatSession>(&content) {
304                    let session_id = session.session_id.clone().unwrap_or_else(|| {
305                        path.file_stem()
306                            .map(|s| s.to_string_lossy().to_string())
307                            .unwrap_or_default()
308                    });
309
310                    if !indexed_ids.contains(&session_id) {
311                        let title = session.title();
312                        let msg_count = session.requests.len();
313                        orphaned_sessions.push((session_id, title, msg_count, path.clone()));
314                    }
315                }
316            }
317        }
318    }
319
320    if orphaned_sessions.is_empty() {
321        println!(
322            "\n{} No orphaned sessions found - all sessions are registered!",
323            "[OK]".green().bold()
324        );
325        return Ok(());
326    }
327
328    println!(
329        "\n{} Found {} orphaned sessions (on disk but not in index):\n",
330        "[!]".yellow().bold(),
331        orphaned_sessions.len().to_string().red()
332    );
333
334    for (session_id, title, msg_count, _path) in &orphaned_sessions {
335        let id_display = if session_id.len() > 12 {
336            &session_id[..12]
337        } else {
338            session_id
339        };
340        println!(
341            "   {} {} ({} messages)",
342            id_display.cyan(),
343            format!("\"{}\"", title).yellow(),
344            msg_count
345        );
346    }
347
348    println!("\n{} To register all orphaned sessions:", "->".cyan());
349    println!("   csm register all --force");
350    println!("\n{} To register specific sessions:", "->".cyan());
351    println!("   csm register session <ID1> <ID2> ... --force");
352
353    Ok(())
354}
355
356/// Count session files in a directory
357fn count_sessions_in_directory(dir: &PathBuf) -> Result<usize> {
358    let mut count = 0;
359    for entry in std::fs::read_dir(dir)? {
360        let entry = entry?;
361        if entry
362            .path()
363            .extension()
364            .map(|e| e == "json")
365            .unwrap_or(false)
366        {
367            count += 1;
368        }
369    }
370    Ok(count)
371}
372
373/// Find a session file by ID (supports partial matches)
374fn find_session_file(chat_sessions_dir: &PathBuf, session_id: &str) -> Result<PathBuf> {
375    // First try exact match
376    let exact_path = chat_sessions_dir.join(format!("{}.json", session_id));
377    if exact_path.exists() {
378        return Ok(exact_path);
379    }
380
381    // Try partial match (prefix)
382    for entry in std::fs::read_dir(chat_sessions_dir)? {
383        let entry = entry?;
384        let path = entry.path();
385
386        if path.extension().map(|e| e == "json").unwrap_or(false) {
387            let filename = path
388                .file_stem()
389                .map(|s| s.to_string_lossy().to_string())
390                .unwrap_or_default();
391
392            if filename.starts_with(session_id) {
393                return Ok(path);
394            }
395
396            // Also check session_id inside the file
397            if let Ok(content) = std::fs::read_to_string(&path) {
398                if let Ok(session) = serde_json::from_str::<ChatSession>(&content) {
399                    if let Some(ref sid) = session.session_id {
400                        if sid.starts_with(session_id) || sid == session_id {
401                            return Ok(path);
402                        }
403                    }
404                }
405            }
406        }
407    }
408
409    Err(CsmError::SessionNotFound(session_id.to_string()).into())
410}
411
412/// Find sessions by title (case-insensitive partial match)
413fn find_sessions_by_titles(
414    chat_sessions_dir: &PathBuf,
415    titles: &[String],
416) -> Result<Vec<(ChatSession, PathBuf)>> {
417    let mut matches = Vec::new();
418    let title_patterns: Vec<String> = titles.iter().map(|t| t.to_lowercase()).collect();
419
420    for entry in std::fs::read_dir(chat_sessions_dir)? {
421        let entry = entry?;
422        let path = entry.path();
423
424        if path.extension().map(|e| e == "json").unwrap_or(false) {
425            if let Ok(content) = std::fs::read_to_string(&path) {
426                if let Ok(session) = serde_json::from_str::<ChatSession>(&content) {
427                    let session_title = session.title().to_lowercase();
428
429                    for pattern in &title_patterns {
430                        if session_title.contains(pattern) {
431                            matches.push((session, path.clone()));
432                            break;
433                        }
434                    }
435                }
436            }
437        }
438    }
439
440    if matches.is_empty() {
441        println!(
442            "{} No sessions found matching the specified titles",
443            "[!]".yellow()
444        );
445    }
446
447    Ok(matches)
448}