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