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