Skip to main content

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::{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::{discover_workspaces, find_workspace_by_path, normalize_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}
454
455/// Recursively walk directories and register orphaned sessions for all workspaces found
456pub fn register_recursive(
457    root_path: Option<&str>,
458    max_depth: Option<usize>,
459    force: bool,
460    dry_run: bool,
461    exclude_patterns: &[String],
462) -> Result<()> {
463    let root = resolve_path(root_path);
464
465    println!(
466        "{} Scanning for workspaces recursively from: {}",
467        "[CSM]".cyan().bold(),
468        root.display()
469    );
470
471    if dry_run {
472        println!("{} Dry run mode - no changes will be made", "[!]".yellow());
473    }
474
475    // Check if VS Code is running
476    if !force && !dry_run && is_vscode_running() {
477        println!(
478            "{} VS Code is running. Use {} to register anyway.",
479            "[!]".yellow(),
480            "--force".cyan()
481        );
482        println!("   Note: VS Code uses WAL mode so this is generally safe.");
483        return Err(CsmError::VSCodeRunning.into());
484    }
485
486    // Get all VS Code workspaces
487    let workspaces = discover_workspaces()?;
488    println!(
489        "   Found {} VS Code workspaces to check",
490        workspaces.len().to_string().cyan()
491    );
492
493    // Build a map of normalized project paths to workspace info
494    let mut workspace_map: std::collections::HashMap<String, Vec<&crate::models::Workspace>> =
495        std::collections::HashMap::new();
496    for ws in &workspaces {
497        if let Some(ref project_path) = ws.project_path {
498            let normalized = normalize_path(project_path);
499            workspace_map.entry(normalized).or_default().push(ws);
500        }
501    }
502
503    // Compile exclude patterns
504    let exclude_matchers: Vec<glob::Pattern> = exclude_patterns
505        .iter()
506        .filter_map(|p| glob::Pattern::new(p).ok())
507        .collect();
508
509    // Default exclusions for common non-project directories
510    let default_excludes = [
511        "node_modules",
512        ".git",
513        "target",
514        "build",
515        "dist",
516        ".venv",
517        "venv",
518        "__pycache__",
519        ".cache",
520        "vendor",
521        ".cargo",
522    ];
523
524    let mut total_dirs_scanned = 0;
525    let mut workspaces_found = 0;
526    let mut total_sessions_registered = 0;
527    let mut workspaces_with_orphans: Vec<(String, usize, usize)> = Vec::new();
528
529    // Walk the directory tree
530    walk_directory(
531        &root,
532        &root,
533        0,
534        max_depth,
535        &workspace_map,
536        &exclude_matchers,
537        &default_excludes,
538        force,
539        dry_run,
540        &mut total_dirs_scanned,
541        &mut workspaces_found,
542        &mut total_sessions_registered,
543        &mut workspaces_with_orphans,
544    )?;
545
546    // Print summary
547    println!("\n{}", "═".repeat(60).cyan());
548    println!("{} Recursive scan complete", "[OK]".green().bold());
549    println!("{}", "═".repeat(60).cyan());
550    println!(
551        "   Directories scanned:    {}",
552        total_dirs_scanned.to_string().cyan()
553    );
554    println!(
555        "   Workspaces found:       {}",
556        workspaces_found.to_string().cyan()
557    );
558    println!(
559        "   Sessions registered:    {}",
560        total_sessions_registered.to_string().green()
561    );
562
563    if !workspaces_with_orphans.is_empty() {
564        println!("\n   {} Workspaces with orphaned sessions:", "[+]".green());
565        for (path, orphaned, registered) in &workspaces_with_orphans {
566            let reg_str = if dry_run {
567                format!("would register {}", registered)
568            } else {
569                format!("registered {}", registered)
570            };
571            println!(
572                "      {} ({} orphaned, {})",
573                path.cyan(),
574                orphaned.to_string().yellow(),
575                reg_str.green()
576            );
577        }
578    }
579
580    if total_sessions_registered > 0 && !dry_run {
581        println!(
582            "\n{} VS Code caches the session index in memory.",
583            "[!]".yellow()
584        );
585        println!("   To see the new sessions, do one of the following:");
586        println!(
587            "   * Run: {} (if CSM extension is installed)",
588            "code --command csm.reloadAndShowChats".cyan()
589        );
590        println!(
591            "   * Or press {} in VS Code and run {}",
592            "Ctrl+Shift+P".cyan(),
593            "Developer: Reload Window".cyan()
594        );
595        println!("   * Or restart VS Code");
596    }
597
598    Ok(())
599}
600
601/// Recursively walk a directory and process workspaces
602#[allow(clippy::too_many_arguments)]
603fn walk_directory(
604    current_dir: &Path,
605    root: &Path,
606    current_depth: usize,
607    max_depth: Option<usize>,
608    workspace_map: &std::collections::HashMap<String, Vec<&crate::models::Workspace>>,
609    exclude_matchers: &[glob::Pattern],
610    default_excludes: &[&str],
611    force: bool,
612    dry_run: bool,
613    total_dirs_scanned: &mut usize,
614    workspaces_found: &mut usize,
615    total_sessions_registered: &mut usize,
616    workspaces_with_orphans: &mut Vec<(String, usize, usize)>,
617) -> Result<()> {
618    // Check depth limit
619    if let Some(max) = max_depth {
620        if current_depth > max {
621            return Ok(());
622        }
623    }
624
625    *total_dirs_scanned += 1;
626
627    // Get directory name for exclusion checking
628    let dir_name = current_dir
629        .file_name()
630        .map(|n| n.to_string_lossy().to_string())
631        .unwrap_or_default();
632
633    // Skip default excluded directories
634    if default_excludes.contains(&dir_name.as_str()) {
635        return Ok(());
636    }
637
638    // Skip if matches user exclusion patterns
639    let relative_path = current_dir
640        .strip_prefix(root)
641        .unwrap_or(current_dir)
642        .to_string_lossy();
643    for pattern in exclude_matchers {
644        if pattern.matches(&relative_path) || pattern.matches(&dir_name) {
645            return Ok(());
646        }
647    }
648
649    // Check if this directory is a VS Code workspace
650    let normalized_path = normalize_path(&current_dir.to_string_lossy());
651    if let Some(workspace_entries) = workspace_map.get(&normalized_path) {
652        *workspaces_found += 1;
653
654        for ws in workspace_entries {
655            // Check for orphaned sessions in this workspace
656            if ws.has_chat_sessions {
657                let chat_sessions_dir = &ws.chat_sessions_path;
658
659                // Count orphaned sessions
660                match count_orphaned_sessions(&ws.hash, chat_sessions_dir) {
661                    Ok((on_disk, in_index, orphaned_count)) => {
662                        if orphaned_count > 0 {
663                            let display_path = ws.project_path.as_deref().unwrap_or(&ws.hash);
664
665                            if dry_run {
666                                println!(
667                                    "   {} {} - {} sessions on disk, {} in index, {} orphaned",
668                                    "[DRY]".yellow(),
669                                    display_path.cyan(),
670                                    on_disk.to_string().white(),
671                                    in_index.to_string().white(),
672                                    orphaned_count.to_string().yellow()
673                                );
674                                workspaces_with_orphans.push((
675                                    display_path.to_string(),
676                                    orphaned_count,
677                                    orphaned_count,
678                                ));
679                            } else {
680                                // Register the sessions
681                                match register_all_sessions_from_directory(
682                                    &ws.hash,
683                                    chat_sessions_dir,
684                                    force,
685                                ) {
686                                    Ok(registered) => {
687                                        *total_sessions_registered += registered;
688                                        println!(
689                                            "   {} {} - registered {} sessions",
690                                            "[+]".green(),
691                                            display_path.cyan(),
692                                            registered.to_string().green()
693                                        );
694                                        workspaces_with_orphans.push((
695                                            display_path.to_string(),
696                                            orphaned_count,
697                                            registered,
698                                        ));
699                                    }
700                                    Err(e) => {
701                                        println!(
702                                            "   {} {} - error: {}",
703                                            "[!]".red(),
704                                            display_path.cyan(),
705                                            e
706                                        );
707                                    }
708                                }
709                            }
710                        }
711                    }
712                    Err(e) => {
713                        let display_path = ws.project_path.as_deref().unwrap_or(&ws.hash);
714                        println!(
715                            "   {} {} - error checking: {}",
716                            "[!]".yellow(),
717                            display_path,
718                            e
719                        );
720                    }
721                }
722            }
723        }
724    }
725
726    // Recurse into subdirectories
727    match std::fs::read_dir(current_dir) {
728        Ok(entries) => {
729            for entry in entries.flatten() {
730                let path = entry.path();
731                if path.is_dir() {
732                    // Skip hidden directories
733                    let name = path
734                        .file_name()
735                        .map(|n| n.to_string_lossy().to_string())
736                        .unwrap_or_default();
737                    if name.starts_with('.') {
738                        continue;
739                    }
740
741                    walk_directory(
742                        &path,
743                        root,
744                        current_depth + 1,
745                        max_depth,
746                        workspace_map,
747                        exclude_matchers,
748                        default_excludes,
749                        force,
750                        dry_run,
751                        total_dirs_scanned,
752                        workspaces_found,
753                        total_sessions_registered,
754                        workspaces_with_orphans,
755                    )?;
756                }
757            }
758        }
759        Err(e) => {
760            // Permission denied or other errors - skip silently
761            if e.kind() != std::io::ErrorKind::PermissionDenied {
762                eprintln!(
763                    "   {} Could not read {}: {}",
764                    "[!]".yellow(),
765                    current_dir.display(),
766                    e
767                );
768            }
769        }
770    }
771
772    Ok(())
773}
774
775/// Count orphaned sessions in a workspace (on disk but not in index)
776fn count_orphaned_sessions(
777    workspace_id: &str,
778    chat_sessions_dir: &Path,
779) -> Result<(usize, usize, usize)> {
780    // Get sessions in index
781    let db_path = get_workspace_storage_db(workspace_id)?;
782    let indexed_sessions = read_chat_session_index(&db_path)?;
783    let indexed_ids: HashSet<String> = indexed_sessions.entries.keys().cloned().collect();
784
785    // Count sessions on disk
786    let mut on_disk = 0;
787    let mut orphaned = 0;
788
789    for entry in std::fs::read_dir(chat_sessions_dir)? {
790        let entry = entry?;
791        let path = entry.path();
792
793        if path.extension().map(|e| e == "json").unwrap_or(false) {
794            on_disk += 1;
795
796            // Check if it's in the index
797            let filename = path
798                .file_stem()
799                .map(|s| s.to_string_lossy().to_string())
800                .unwrap_or_default();
801
802            if !indexed_ids.contains(&filename) {
803                orphaned += 1;
804            }
805        }
806    }
807
808    Ok((on_disk, indexed_ids.len(), orphaned))
809}