Skip to main content

chasm/commands/
register.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: AGPL-3.0-only
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::io::Write;
13use std::path::{Path, PathBuf};
14
15use crate::error::CsmError;
16use crate::models::ChatSession;
17use crate::storage::{
18    add_session_to_index, close_vscode_and_wait, get_workspace_storage_db,
19    is_session_file_extension, is_vscode_running, parse_session_file, parse_session_json,
20    read_chat_session_index, register_all_sessions_from_directory, reopen_vscode,
21    repair_workspace_sessions,
22};
23use crate::workspace::{discover_workspaces, find_workspace_by_path, normalize_path};
24
25/// Prompt the user to confirm closing VS Code. Returns true if confirmed, false if declined.
26/// When `force` is true, skips the prompt and returns true immediately.
27fn confirm_close_vscode(force: bool) -> bool {
28    if force {
29        return true;
30    }
31    print!(
32        "{} VS Code will be closed. Continue? [y/N] ",
33        "[?]".yellow()
34    );
35    std::io::stdout().flush().ok();
36    let mut input = String::new();
37    if std::io::stdin().read_line(&mut input).is_err() {
38        return false;
39    }
40    matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
41}
42
43/// Resolve a path option to an absolute PathBuf, handling "." and relative paths
44pub fn resolve_path(path: Option<&str>) -> PathBuf {
45    match path {
46        Some(p) => {
47            let path = PathBuf::from(p);
48            path.canonicalize().unwrap_or(path)
49        }
50        None => std::env::current_dir().unwrap_or_default(),
51    }
52}
53
54/// Register all sessions from a workspace into VS Code's index
55pub fn register_all(
56    project_path: Option<&str>,
57    merge: bool,
58    force: bool,
59    close_vscode: bool,
60    reopen: bool,
61) -> Result<()> {
62    let path = resolve_path(project_path);
63    // --reopen implies --close-vscode
64    let should_close = close_vscode || reopen;
65
66    if merge {
67        println!(
68            "{} Merging and registering all sessions for: {}",
69            "[CSM]".cyan().bold(),
70            path.display()
71        );
72
73        // Use the existing merge functionality
74        let path_str = path.to_string_lossy().to_string();
75        return crate::commands::history_merge(
76            Some(&path_str),
77            None,  // title
78            force, // force
79            false, // no_backup
80        );
81    }
82
83    println!(
84        "{} Registering all sessions for: {}",
85        "[CSM]".cyan().bold(),
86        path.display()
87    );
88
89    // Find the workspace
90    let path_str = path.to_string_lossy().to_string();
91    let (ws_id, ws_path, _folder) = find_workspace_by_path(&path_str)?
92        .ok_or_else(|| CsmError::WorkspaceNotFound(path.display().to_string()))?;
93
94    let chat_sessions_dir = ws_path.join("chatSessions");
95
96    if !chat_sessions_dir.exists() {
97        println!(
98            "{} No chatSessions directory found at: {}",
99            "[!]".yellow(),
100            chat_sessions_dir.display()
101        );
102        return Ok(());
103    }
104
105    // Handle VS Code lifecycle
106    let vscode_was_running = is_vscode_running();
107    if vscode_was_running {
108        if should_close {
109            if !confirm_close_vscode(force) {
110                println!("{} Aborted.", "[!]".yellow());
111                return Ok(());
112            }
113            println!("   {} Closing VS Code (saving state)...", "[*]".yellow());
114            close_vscode_and_wait(30)?;
115            println!("   {} VS Code closed.", "[OK]".green());
116        } else if !force {
117            println!(
118                "{} VS Code is running. Its in-memory cache will overwrite index changes.",
119                "[!]".yellow()
120            );
121            println!(
122                "   Use {} to close VS Code first, register, and reopen.",
123                "--reopen".cyan()
124            );
125            println!(
126                "   Use {} to just close VS Code first.",
127                "--close-vscode".cyan()
128            );
129            println!(
130                "   Use {} to write anyway (works after restarting VS Code).",
131                "--force".cyan()
132            );
133            return Err(CsmError::VSCodeRunning.into());
134        }
135    }
136
137    // Count sessions on disk
138    let sessions_on_disk = count_sessions_in_directory(&chat_sessions_dir)?;
139    println!(
140        "   Found {} session files on disk",
141        sessions_on_disk.to_string().green()
142    );
143
144    // Register all sessions
145    let registered = register_all_sessions_from_directory(&ws_id, &chat_sessions_dir, true)?;
146
147    println!(
148        "\n{} Registered {} sessions in VS Code's index",
149        "[OK]".green().bold(),
150        registered.to_string().cyan()
151    );
152
153    // Reopen VS Code if requested (or if we closed it with --reopen)
154    if reopen && vscode_was_running {
155        println!("   {} Reopening VS Code...", "[*]".yellow());
156        reopen_vscode(Some(&path_str))?;
157        println!(
158            "   {} VS Code launched. Sessions should appear in Copilot Chat history.",
159            "[OK]".green()
160        );
161    } else if should_close && vscode_was_running {
162        println!(
163            "\n{} VS Code was closed. Reopen it to see the recovered sessions.",
164            "[!]".yellow()
165        );
166        println!("   Run: {}", format!("code {}", path.display()).cyan());
167    } else if force && vscode_was_running {
168        // VS Code is still running with --force, show reload instructions
169        println!(
170            "\n{} VS Code caches the session index in memory.",
171            "[!]".yellow()
172        );
173        println!("   To see the new sessions, do one of the following:");
174        println!(
175            "   * Press {} and run {}",
176            "Ctrl+Shift+P".cyan(),
177            "Developer: Reload Window".cyan()
178        );
179        println!("   * Or restart VS Code");
180    }
181
182    Ok(())
183}
184
185/// Register specific sessions by ID or title
186pub fn register_sessions(
187    ids: &[String],
188    titles: Option<&[String]>,
189    project_path: Option<&str>,
190    force: bool,
191) -> Result<()> {
192    let path = resolve_path(project_path);
193
194    // Find the workspace
195    let path_str = path.to_string_lossy().to_string();
196    let (ws_id, ws_path, _folder) = find_workspace_by_path(&path_str)?
197        .ok_or_else(|| CsmError::WorkspaceNotFound(path.display().to_string()))?;
198
199    let chat_sessions_dir = ws_path.join("chatSessions");
200
201    // Check if VS Code is running
202    if !force && is_vscode_running() {
203        println!(
204            "{} VS Code is running. Use {} to register anyway.",
205            "[!]".yellow(),
206            "--force".cyan()
207        );
208        return Err(CsmError::VSCodeRunning.into());
209    }
210
211    // Get the database path
212    let db_path = get_workspace_storage_db(&ws_id)?;
213
214    let mut registered_count = 0;
215
216    if let Some(titles) = titles {
217        // Register by title
218        println!(
219            "{} Registering {} sessions by title:",
220            "[CSM]".cyan().bold(),
221            titles.len()
222        );
223
224        let sessions = find_sessions_by_titles(&chat_sessions_dir, titles)?;
225
226        for (session, session_path) in sessions {
227            let session_id = session.session_id.clone().unwrap_or_else(|| {
228                session_path
229                    .file_stem()
230                    .map(|s| s.to_string_lossy().to_string())
231                    .unwrap_or_default()
232            });
233            let title = session.title();
234
235            add_session_to_index(
236                &db_path,
237                &session_id,
238                &title,
239                session.last_message_date,
240                session.is_imported,
241                &session.initial_location,
242                session.is_empty(),
243            )?;
244
245            let id_display = if session_id.len() > 12 {
246                &session_id[..12]
247            } else {
248                &session_id
249            };
250            println!(
251                "   {} {} (\"{}\")",
252                "[OK]".green(),
253                id_display.cyan(),
254                title.yellow()
255            );
256            registered_count += 1;
257        }
258    } else {
259        // Register by ID (default)
260        println!(
261            "{} Registering {} sessions by ID:",
262            "[CSM]".cyan().bold(),
263            ids.len()
264        );
265
266        for session_id in ids {
267            match find_session_file(&chat_sessions_dir, session_id) {
268                Ok(session_file) => {
269                    let content = std::fs::read_to_string(&session_file)?;
270                    let session: ChatSession = serde_json::from_str(&content)?;
271
272                    let title = session.title();
273                    let actual_session_id = session
274                        .session_id
275                        .clone()
276                        .unwrap_or_else(|| session_id.to_string());
277
278                    add_session_to_index(
279                        &db_path,
280                        &actual_session_id,
281                        &title,
282                        session.last_message_date,
283                        session.is_imported,
284                        &session.initial_location,
285                        session.is_empty(),
286                    )?;
287
288                    let id_display = if actual_session_id.len() > 12 {
289                        &actual_session_id[..12]
290                    } else {
291                        &actual_session_id
292                    };
293                    println!(
294                        "   {} {} (\"{}\")",
295                        "[OK]".green(),
296                        id_display.cyan(),
297                        title.yellow()
298                    );
299                    registered_count += 1;
300                }
301                Err(e) => {
302                    println!(
303                        "   {} {} - {}",
304                        "[ERR]".red(),
305                        session_id.cyan(),
306                        e.to_string().red()
307                    );
308                }
309            }
310        }
311    }
312
313    println!(
314        "\n{} Registered {} sessions in VS Code's index",
315        "[OK]".green().bold(),
316        registered_count.to_string().cyan()
317    );
318
319    if force && is_vscode_running() {
320        println!(
321            "   {} Sessions should appear in VS Code immediately",
322            "->".cyan()
323        );
324    }
325
326    Ok(())
327}
328
329/// List sessions that exist on disk but are not in VS Code's index
330pub fn list_orphaned(project_path: Option<&str>) -> Result<()> {
331    let path = resolve_path(project_path);
332
333    println!(
334        "{} Finding orphaned sessions for: {}",
335        "[CSM]".cyan().bold(),
336        path.display()
337    );
338
339    // Find the workspace
340    let path_str = path.to_string_lossy().to_string();
341    let (ws_id, ws_path, _folder) = find_workspace_by_path(&path_str)?
342        .ok_or_else(|| CsmError::WorkspaceNotFound(path.display().to_string()))?;
343
344    let chat_sessions_dir = ws_path.join("chatSessions");
345
346    if !chat_sessions_dir.exists() {
347        println!("{} No chatSessions directory found", "[!]".yellow());
348        return Ok(());
349    }
350
351    // Get sessions currently in the index
352    let db_path = get_workspace_storage_db(&ws_id)?;
353    let index = read_chat_session_index(&db_path)?;
354    let indexed_ids: HashSet<String> = index.entries.keys().cloned().collect();
355
356    println!(
357        "   {} sessions currently in VS Code's index",
358        indexed_ids.len().to_string().cyan()
359    );
360
361    // Find sessions on disk
362    let mut orphaned_sessions = Vec::new();
363
364    // Collect files, preferring .jsonl over .json for the same session ID
365    let mut session_files: std::collections::HashMap<String, PathBuf> =
366        std::collections::HashMap::new();
367    for entry in std::fs::read_dir(&chat_sessions_dir)? {
368        let entry = entry?;
369        let path = entry.path();
370        if path
371            .extension()
372            .map(is_session_file_extension)
373            .unwrap_or(false)
374        {
375            if let Some(stem) = path.file_stem() {
376                let stem_str = stem.to_string_lossy().to_string();
377                let is_jsonl = path.extension().is_some_and(|e| e == "jsonl");
378                if !session_files.contains_key(&stem_str) || is_jsonl {
379                    session_files.insert(stem_str, path);
380                }
381            }
382        }
383    }
384
385    for (_, path) in &session_files {
386        if let Ok(session) = parse_session_file(path) {
387            let session_id = session.session_id.clone().unwrap_or_else(|| {
388                path.file_stem()
389                    .map(|s| s.to_string_lossy().to_string())
390                    .unwrap_or_default()
391            });
392
393            if !indexed_ids.contains(&session_id) {
394                let title = session.title();
395                let msg_count = session.requests.len();
396                orphaned_sessions.push((session_id, title, msg_count, path.clone()));
397            }
398        }
399    }
400
401    if orphaned_sessions.is_empty() {
402        println!(
403            "\n{} No orphaned sessions found - all sessions are registered!",
404            "[OK]".green().bold()
405        );
406        return Ok(());
407    }
408
409    println!(
410        "\n{} Found {} orphaned sessions (on disk but not in index):\n",
411        "[!]".yellow().bold(),
412        orphaned_sessions.len().to_string().red()
413    );
414
415    for (session_id, title, msg_count, _path) in &orphaned_sessions {
416        let id_display = if session_id.len() > 12 {
417            &session_id[..12]
418        } else {
419            session_id
420        };
421        println!(
422            "   {} {} ({} messages)",
423            id_display.cyan(),
424            format!("\"{}\"", title).yellow(),
425            msg_count
426        );
427    }
428
429    println!("\n{} To register all orphaned sessions:", "->".cyan());
430    println!("   csm register all --force");
431    println!("\n{} To register specific sessions:", "->".cyan());
432    println!("   csm register session <ID1> <ID2> ... --force");
433
434    Ok(())
435}
436
437/// Count session files in a directory (counts unique session IDs, preferring .jsonl)
438fn count_sessions_in_directory(dir: &PathBuf) -> Result<usize> {
439    let mut session_ids: HashSet<String> = HashSet::new();
440    for entry in std::fs::read_dir(dir)? {
441        let entry = entry?;
442        let path = entry.path();
443        if path
444            .extension()
445            .map(is_session_file_extension)
446            .unwrap_or(false)
447        {
448            if let Some(stem) = path.file_stem() {
449                session_ids.insert(stem.to_string_lossy().to_string());
450            }
451        }
452    }
453    Ok(session_ids.len())
454}
455
456/// Find a session file by ID (supports partial matches, prefers .jsonl over .json)
457fn find_session_file(chat_sessions_dir: &PathBuf, session_id: &str) -> Result<PathBuf> {
458    // First try exact match (.jsonl preferred)
459    let exact_jsonl = chat_sessions_dir.join(format!("{}.jsonl", session_id));
460    if exact_jsonl.exists() {
461        return Ok(exact_jsonl);
462    }
463    let exact_json = chat_sessions_dir.join(format!("{}.json", session_id));
464    if exact_json.exists() {
465        return Ok(exact_json);
466    }
467
468    // Try partial match (prefix), preferring .jsonl
469    let mut best_match: Option<PathBuf> = None;
470    for entry in std::fs::read_dir(chat_sessions_dir)? {
471        let entry = entry?;
472        let path = entry.path();
473
474        if path
475            .extension()
476            .map(is_session_file_extension)
477            .unwrap_or(false)
478        {
479            let filename = path
480                .file_stem()
481                .map(|s| s.to_string_lossy().to_string())
482                .unwrap_or_default();
483
484            if filename.starts_with(session_id) {
485                let is_jsonl = path.extension().is_some_and(|e| e == "jsonl");
486                if best_match.is_none() || is_jsonl {
487                    best_match = Some(path.clone());
488                    if is_jsonl {
489                        return Ok(path);
490                    }
491                }
492                continue;
493            }
494
495            // Also check session_id inside the file
496            if let Ok(session) = parse_session_file(&path) {
497                if let Some(ref sid) = session.session_id {
498                    if sid.starts_with(session_id) || sid == session_id {
499                        let is_jsonl = path.extension().is_some_and(|e| e == "jsonl");
500                        if best_match.is_none() || is_jsonl {
501                            best_match = Some(path.clone());
502                        }
503                    }
504                }
505            }
506        }
507    }
508
509    best_match.ok_or_else(|| CsmError::SessionNotFound(session_id.to_string()).into())
510}
511
512/// Find sessions by title (case-insensitive partial match)
513fn find_sessions_by_titles(
514    chat_sessions_dir: &PathBuf,
515    titles: &[String],
516) -> Result<Vec<(ChatSession, PathBuf)>> {
517    let mut matches = Vec::new();
518    let title_patterns: Vec<String> = titles.iter().map(|t| t.to_lowercase()).collect();
519
520    // Collect files, preferring .jsonl over .json for the same session ID
521    let mut session_files: std::collections::HashMap<String, PathBuf> =
522        std::collections::HashMap::new();
523    for entry in std::fs::read_dir(chat_sessions_dir)? {
524        let entry = entry?;
525        let path = entry.path();
526        if path
527            .extension()
528            .map(is_session_file_extension)
529            .unwrap_or(false)
530        {
531            if let Some(stem) = path.file_stem() {
532                let stem_str = stem.to_string_lossy().to_string();
533                let is_jsonl = path.extension().is_some_and(|e| e == "jsonl");
534                if !session_files.contains_key(&stem_str) || is_jsonl {
535                    session_files.insert(stem_str, path);
536                }
537            }
538        }
539    }
540
541    for (_, path) in &session_files {
542        if let Ok(session) = parse_session_file(path) {
543            let session_title = session.title().to_lowercase();
544
545            for pattern in &title_patterns {
546                if session_title.contains(pattern) {
547                    matches.push((session, path.clone()));
548                    break;
549                }
550            }
551        }
552    }
553
554    if matches.is_empty() {
555        println!(
556            "{} No sessions found matching the specified titles",
557            "[!]".yellow()
558        );
559    }
560
561    Ok(matches)
562}
563
564/// Recursively walk directories and register orphaned sessions for all workspaces found
565pub fn register_recursive(
566    root_path: Option<&str>,
567    max_depth: Option<usize>,
568    force: bool,
569    dry_run: bool,
570    exclude_patterns: &[String],
571) -> Result<()> {
572    let root = resolve_path(root_path);
573
574    println!(
575        "{} Scanning for workspaces recursively from: {}",
576        "[CSM]".cyan().bold(),
577        root.display()
578    );
579
580    if dry_run {
581        println!("{} Dry run mode - no changes will be made", "[!]".yellow());
582    }
583
584    // Check if VS Code is running
585    if !force && !dry_run && is_vscode_running() {
586        println!(
587            "{} VS Code is running. Use {} to register anyway.",
588            "[!]".yellow(),
589            "--force".cyan()
590        );
591        println!("   Note: VS Code uses WAL mode so this is generally safe.");
592        return Err(CsmError::VSCodeRunning.into());
593    }
594
595    // Get all VS Code workspaces
596    let workspaces = discover_workspaces()?;
597    println!(
598        "   Found {} VS Code workspaces to check",
599        workspaces.len().to_string().cyan()
600    );
601
602    // Build a map of normalized project paths to workspace info
603    let mut workspace_map: std::collections::HashMap<String, Vec<&crate::models::Workspace>> =
604        std::collections::HashMap::new();
605    for ws in &workspaces {
606        if let Some(ref project_path) = ws.project_path {
607            let normalized = normalize_path(project_path);
608            workspace_map.entry(normalized).or_default().push(ws);
609        }
610    }
611
612    // Compile exclude patterns
613    let exclude_matchers: Vec<glob::Pattern> = exclude_patterns
614        .iter()
615        .filter_map(|p| glob::Pattern::new(p).ok())
616        .collect();
617
618    // Default exclusions for common non-project directories
619    let default_excludes = [
620        "node_modules",
621        ".git",
622        "target",
623        "build",
624        "dist",
625        ".venv",
626        "venv",
627        "__pycache__",
628        ".cache",
629        "vendor",
630        ".cargo",
631    ];
632
633    let mut total_dirs_scanned = 0;
634    let mut workspaces_found = 0;
635    let mut total_sessions_registered = 0;
636    let mut workspaces_with_orphans: Vec<(String, usize, usize)> = Vec::new();
637
638    // Walk the directory tree
639    walk_directory(
640        &root,
641        &root,
642        0,
643        max_depth,
644        &workspace_map,
645        &exclude_matchers,
646        &default_excludes,
647        force,
648        dry_run,
649        &mut total_dirs_scanned,
650        &mut workspaces_found,
651        &mut total_sessions_registered,
652        &mut workspaces_with_orphans,
653    )?;
654
655    // Print summary
656    println!("\n{}", "═".repeat(60).cyan());
657    println!("{} Recursive scan complete", "[OK]".green().bold());
658    println!("{}", "═".repeat(60).cyan());
659    println!(
660        "   Directories scanned:    {}",
661        total_dirs_scanned.to_string().cyan()
662    );
663    println!(
664        "   Workspaces found:       {}",
665        workspaces_found.to_string().cyan()
666    );
667    println!(
668        "   Sessions registered:    {}",
669        total_sessions_registered.to_string().green()
670    );
671
672    if !workspaces_with_orphans.is_empty() {
673        println!("\n   {} Workspaces with orphaned sessions:", "[+]".green());
674        for (path, orphaned, registered) in &workspaces_with_orphans {
675            let reg_str = if dry_run {
676                format!("would register {}", registered)
677            } else {
678                format!("registered {}", registered)
679            };
680            println!(
681                "      {} ({} orphaned, {})",
682                path.cyan(),
683                orphaned.to_string().yellow(),
684                reg_str.green()
685            );
686        }
687    }
688
689    if total_sessions_registered > 0 && !dry_run {
690        println!(
691            "\n{} VS Code caches the session index in memory.",
692            "[!]".yellow()
693        );
694        println!("   To see the new sessions, do one of the following:");
695        println!(
696            "   * Run: {} (if CSM extension is installed)",
697            "code --command csm.reloadAndShowChats".cyan()
698        );
699        println!(
700            "   * Or press {} in VS Code and run {}",
701            "Ctrl+Shift+P".cyan(),
702            "Developer: Reload Window".cyan()
703        );
704        println!("   * Or restart VS Code");
705    }
706
707    Ok(())
708}
709
710/// Recursively walk a directory and process workspaces
711#[allow(clippy::too_many_arguments)]
712fn walk_directory(
713    current_dir: &Path,
714    root: &Path,
715    current_depth: usize,
716    max_depth: Option<usize>,
717    workspace_map: &std::collections::HashMap<String, Vec<&crate::models::Workspace>>,
718    exclude_matchers: &[glob::Pattern],
719    default_excludes: &[&str],
720    force: bool,
721    dry_run: bool,
722    total_dirs_scanned: &mut usize,
723    workspaces_found: &mut usize,
724    total_sessions_registered: &mut usize,
725    workspaces_with_orphans: &mut Vec<(String, usize, usize)>,
726) -> Result<()> {
727    // Check depth limit
728    if let Some(max) = max_depth {
729        if current_depth > max {
730            return Ok(());
731        }
732    }
733
734    *total_dirs_scanned += 1;
735
736    // Get directory name for exclusion checking
737    let dir_name = current_dir
738        .file_name()
739        .map(|n| n.to_string_lossy().to_string())
740        .unwrap_or_default();
741
742    // Skip default excluded directories
743    if default_excludes.contains(&dir_name.as_str()) {
744        return Ok(());
745    }
746
747    // Skip if matches user exclusion patterns
748    let relative_path = current_dir
749        .strip_prefix(root)
750        .unwrap_or(current_dir)
751        .to_string_lossy();
752    for pattern in exclude_matchers {
753        if pattern.matches(&relative_path) || pattern.matches(&dir_name) {
754            return Ok(());
755        }
756    }
757
758    // Check if this directory is a VS Code workspace
759    let normalized_path = normalize_path(&current_dir.to_string_lossy());
760    if let Some(workspace_entries) = workspace_map.get(&normalized_path) {
761        *workspaces_found += 1;
762
763        for ws in workspace_entries {
764            // Check for orphaned sessions in this workspace
765            if ws.has_chat_sessions {
766                let chat_sessions_dir = &ws.chat_sessions_path;
767
768                // Count orphaned sessions
769                match count_orphaned_sessions(&ws.hash, chat_sessions_dir) {
770                    Ok((on_disk, in_index, orphaned_count)) => {
771                        if orphaned_count > 0 {
772                            let display_path = ws.project_path.as_deref().unwrap_or(&ws.hash);
773
774                            if dry_run {
775                                println!(
776                                    "   {} {} - {} sessions on disk, {} in index, {} orphaned",
777                                    "[DRY]".yellow(),
778                                    display_path.cyan(),
779                                    on_disk.to_string().white(),
780                                    in_index.to_string().white(),
781                                    orphaned_count.to_string().yellow()
782                                );
783                                workspaces_with_orphans.push((
784                                    display_path.to_string(),
785                                    orphaned_count,
786                                    orphaned_count,
787                                ));
788                            } else {
789                                // Register the sessions
790                                match register_all_sessions_from_directory(
791                                    &ws.hash,
792                                    chat_sessions_dir,
793                                    force,
794                                ) {
795                                    Ok(registered) => {
796                                        *total_sessions_registered += registered;
797                                        println!(
798                                            "   {} {} - registered {} sessions",
799                                            "[+]".green(),
800                                            display_path.cyan(),
801                                            registered.to_string().green()
802                                        );
803                                        workspaces_with_orphans.push((
804                                            display_path.to_string(),
805                                            orphaned_count,
806                                            registered,
807                                        ));
808                                    }
809                                    Err(e) => {
810                                        println!(
811                                            "   {} {} - error: {}",
812                                            "[!]".red(),
813                                            display_path.cyan(),
814                                            e
815                                        );
816                                    }
817                                }
818                            }
819                        }
820                    }
821                    Err(e) => {
822                        let display_path = ws.project_path.as_deref().unwrap_or(&ws.hash);
823                        println!(
824                            "   {} {} - error checking: {}",
825                            "[!]".yellow(),
826                            display_path,
827                            e
828                        );
829                    }
830                }
831            }
832        }
833    }
834
835    // Recurse into subdirectories
836    match std::fs::read_dir(current_dir) {
837        Ok(entries) => {
838            for entry in entries.flatten() {
839                let path = entry.path();
840                if path.is_dir() {
841                    // Skip hidden directories
842                    let name = path
843                        .file_name()
844                        .map(|n| n.to_string_lossy().to_string())
845                        .unwrap_or_default();
846                    if name.starts_with('.') {
847                        continue;
848                    }
849
850                    walk_directory(
851                        &path,
852                        root,
853                        current_depth + 1,
854                        max_depth,
855                        workspace_map,
856                        exclude_matchers,
857                        default_excludes,
858                        force,
859                        dry_run,
860                        total_dirs_scanned,
861                        workspaces_found,
862                        total_sessions_registered,
863                        workspaces_with_orphans,
864                    )?;
865                }
866            }
867        }
868        Err(e) => {
869            // Permission denied or other errors - skip silently
870            if e.kind() != std::io::ErrorKind::PermissionDenied {
871                eprintln!(
872                    "   {} Could not read {}: {}",
873                    "[!]".yellow(),
874                    current_dir.display(),
875                    e
876                );
877            }
878        }
879    }
880
881    Ok(())
882}
883
884/// Count orphaned sessions in a workspace (on disk but not in index)
885fn count_orphaned_sessions(
886    workspace_id: &str,
887    chat_sessions_dir: &Path,
888) -> Result<(usize, usize, usize)> {
889    // Get sessions in index
890    let db_path = get_workspace_storage_db(workspace_id)?;
891    let indexed_sessions = read_chat_session_index(&db_path)?;
892    let indexed_ids: HashSet<String> = indexed_sessions.entries.keys().cloned().collect();
893
894    // Count unique sessions on disk (preferring .jsonl over .json)
895    let mut disk_sessions: HashSet<String> = HashSet::new();
896
897    for entry in std::fs::read_dir(chat_sessions_dir)? {
898        let entry = entry?;
899        let path = entry.path();
900
901        if path
902            .extension()
903            .map(is_session_file_extension)
904            .unwrap_or(false)
905        {
906            if let Some(stem) = path.file_stem() {
907                disk_sessions.insert(stem.to_string_lossy().to_string());
908            }
909        }
910    }
911
912    let on_disk = disk_sessions.len();
913    let orphaned = disk_sessions
914        .iter()
915        .filter(|id| !indexed_ids.contains(*id))
916        .count();
917
918    Ok((on_disk, indexed_ids.len(), orphaned))
919}
920
921/// Repair sessions: compact JSONL files and rebuild the index with correct metadata
922pub fn register_repair(
923    project_path: Option<&str>,
924    all: bool,
925    force: bool,
926    close_vscode: bool,
927    reopen: bool,
928) -> Result<()> {
929    if all {
930        return register_repair_all(force, close_vscode, reopen);
931    }
932
933    let path = resolve_path(project_path);
934    let should_close = close_vscode || reopen;
935
936    println!(
937        "{} Repairing sessions for: {}",
938        "[CSM]".cyan().bold(),
939        path.display()
940    );
941
942    // Find the workspace
943    let path_str = path.to_string_lossy().to_string();
944    let (ws_id, ws_path, _folder) = find_workspace_by_path(&path_str)?
945        .ok_or_else(|| CsmError::WorkspaceNotFound(path.display().to_string()))?;
946
947    let chat_sessions_dir = ws_path.join("chatSessions");
948
949    if !chat_sessions_dir.exists() {
950        println!(
951            "{} No chatSessions directory found at: {}",
952            "[!]".yellow(),
953            chat_sessions_dir.display()
954        );
955        return Ok(());
956    }
957
958    // Handle VS Code lifecycle
959    let vscode_was_running = is_vscode_running();
960    if vscode_was_running {
961        if should_close {
962            if !confirm_close_vscode(force) {
963                println!("{} Aborted.", "[!]".yellow());
964                return Ok(());
965            }
966            println!("   {} Closing VS Code (saving state)...", "[*]".yellow());
967            close_vscode_and_wait(30)?;
968            println!("   {} VS Code closed.", "[OK]".green());
969        } else if !force {
970            println!(
971                "{} VS Code is running. Its in-memory cache will overwrite index changes.",
972                "[!]".yellow()
973            );
974            println!(
975                "   Use {} to close VS Code first, or {} to force.",
976                "--reopen".cyan(),
977                "--force".cyan()
978            );
979            return Err(CsmError::VSCodeRunning.into());
980        }
981    }
982
983    // Run the repair
984    println!("   {} Pass 1: Compacting JSONL files...", "[*]".cyan());
985    let (compacted, index_fixed) = repair_workspace_sessions(&ws_id, &chat_sessions_dir, true)?;
986
987    println!("   {} Pass 2: Index rebuilt.", "[*]".cyan());
988    println!(
989        "\n{} Repair complete: {} files compacted, {} index entries synced",
990        "[OK]".green().bold(),
991        compacted.to_string().cyan(),
992        index_fixed.to_string().cyan()
993    );
994
995    // Delete stale .json files when a .jsonl exists for the same session
996    let mut deleted_json = 0;
997    if chat_sessions_dir.exists() {
998        let mut jsonl_sessions: HashSet<String> = HashSet::new();
999        for entry in std::fs::read_dir(&chat_sessions_dir)? {
1000            let entry = entry?;
1001            let p = entry.path();
1002            if p.extension().is_some_and(|e| e == "jsonl") {
1003                if let Some(stem) = p.file_stem() {
1004                    jsonl_sessions.insert(stem.to_string_lossy().to_string());
1005                }
1006            }
1007        }
1008        for entry in std::fs::read_dir(&chat_sessions_dir)? {
1009            let entry = entry?;
1010            let p = entry.path();
1011            if p.extension().is_some_and(|e| e == "json") {
1012                if let Some(stem) = p.file_stem() {
1013                    if jsonl_sessions.contains(&stem.to_string_lossy().to_string()) {
1014                        // Rename to .json.bak to preserve as backup
1015                        let bak = p.with_extension("json.bak");
1016                        std::fs::rename(&p, &bak)?;
1017                        println!(
1018                            "   {} Backed up stale .json: {} → {}",
1019                            "[*]".yellow(),
1020                            p.file_name().unwrap_or_default().to_string_lossy(),
1021                            bak.file_name().unwrap_or_default().to_string_lossy()
1022                        );
1023                        deleted_json += 1;
1024                    }
1025                }
1026            }
1027        }
1028        if deleted_json > 0 {
1029            // Re-sync index after removing .json files
1030            repair_workspace_sessions(&ws_id, &chat_sessions_dir, true)?;
1031            println!(
1032                "   {} Removed {} stale .json duplicates (backed up as .json.bak)",
1033                "[OK]".green(),
1034                deleted_json
1035            );
1036        }
1037    }
1038
1039    // Reopen VS Code if requested
1040    if reopen && vscode_was_running {
1041        println!("   {} Reopening VS Code...", "[*]".yellow());
1042        reopen_vscode(Some(&path_str))?;
1043        println!(
1044            "   {} VS Code launched. Sessions should now load correctly.",
1045            "[OK]".green()
1046        );
1047    } else if should_close && vscode_was_running {
1048        println!(
1049            "\n{} VS Code was closed. Reopen it to see the repaired sessions.",
1050            "[!]".yellow()
1051        );
1052        println!("   Run: {}", format!("code {}", path.display()).cyan());
1053    }
1054
1055    Ok(())
1056}
1057
1058/// Repair all workspaces that have chat sessions
1059fn register_repair_all(force: bool, close_vscode: bool, reopen: bool) -> Result<()> {
1060    let should_close = close_vscode || reopen;
1061
1062    println!(
1063        "{} Repairing all workspaces with chat sessions...\n",
1064        "[CSM]".cyan().bold(),
1065    );
1066
1067    // Handle VS Code lifecycle once for all workspaces
1068    let vscode_was_running = is_vscode_running();
1069    if vscode_was_running {
1070        if should_close {
1071            if !confirm_close_vscode(force) {
1072                println!("{} Aborted.", "[!]".yellow());
1073                return Ok(());
1074            }
1075            println!("   {} Closing VS Code (saving state)...", "[*]".yellow());
1076            close_vscode_and_wait(30)?;
1077            println!("   {} VS Code closed.\n", "[OK]".green());
1078        } else if !force {
1079            println!(
1080                "{} VS Code is running. Its in-memory cache will overwrite index changes.",
1081                "[!]".yellow()
1082            );
1083            println!(
1084                "   Use {} to close VS Code first, or {} to force.",
1085                "--reopen".cyan(),
1086                "--force".cyan()
1087            );
1088            return Err(CsmError::VSCodeRunning.into());
1089        }
1090    }
1091
1092    let workspaces = discover_workspaces()?;
1093    let ws_with_sessions: Vec<_> = workspaces
1094        .iter()
1095        .filter(|w| w.has_chat_sessions && w.chat_session_count > 0)
1096        .collect();
1097
1098    if ws_with_sessions.is_empty() {
1099        println!("{} No workspaces with chat sessions found.", "[!]".yellow());
1100        return Ok(());
1101    }
1102
1103    println!(
1104        "   Found {} workspaces with chat sessions\n",
1105        ws_with_sessions.len().to_string().cyan()
1106    );
1107
1108    let mut total_compacted = 0usize;
1109    let mut total_synced = 0usize;
1110    let mut succeeded = 0usize;
1111    let mut failed = 0usize;
1112
1113    for (i, ws) in ws_with_sessions.iter().enumerate() {
1114        let display_name = ws
1115            .project_path
1116            .as_deref()
1117            .unwrap_or(&ws.hash);
1118        println!(
1119            "[{}/{}] {} {}",
1120            i + 1,
1121            ws_with_sessions.len(),
1122            "===".dimmed(),
1123            display_name.cyan()
1124        );
1125
1126        let chat_sessions_dir = ws.workspace_path.join("chatSessions");
1127        if !chat_sessions_dir.exists() {
1128            println!("   {} No chatSessions directory, skipping.\n", "[!]".yellow());
1129            continue;
1130        }
1131
1132        match repair_workspace_sessions(&ws.hash, &chat_sessions_dir, true) {
1133            Ok((compacted, index_fixed)) => {
1134                // Delete stale .json files when a .jsonl exists for the same session
1135                let mut deleted_json = 0;
1136                let mut jsonl_sessions: HashSet<String> = HashSet::new();
1137                for entry in std::fs::read_dir(&chat_sessions_dir)? {
1138                    let entry = entry?;
1139                    let p = entry.path();
1140                    if p.extension().is_some_and(|e| e == "jsonl") {
1141                        if let Some(stem) = p.file_stem() {
1142                            jsonl_sessions.insert(stem.to_string_lossy().to_string());
1143                        }
1144                    }
1145                }
1146                for entry in std::fs::read_dir(&chat_sessions_dir)? {
1147                    let entry = entry?;
1148                    let p = entry.path();
1149                    if p.extension().is_some_and(|e| e == "json") {
1150                        if let Some(stem) = p.file_stem() {
1151                            if jsonl_sessions.contains(&stem.to_string_lossy().to_string()) {
1152                                let bak = p.with_extension("json.bak");
1153                                std::fs::rename(&p, &bak)?;
1154                                deleted_json += 1;
1155                            }
1156                        }
1157                    }
1158                }
1159                if deleted_json > 0 {
1160                    repair_workspace_sessions(&ws.hash, &chat_sessions_dir, true)?;
1161                }
1162
1163                total_compacted += compacted;
1164                total_synced += index_fixed;
1165                succeeded += 1;
1166                println!(
1167                    "   {} {} compacted, {} synced{}\n",
1168                    "[OK]".green(),
1169                    compacted,
1170                    index_fixed,
1171                    if deleted_json > 0 {
1172                        format!(", {} stale .json backed up", deleted_json)
1173                    } else {
1174                        String::new()
1175                    }
1176                );
1177            }
1178            Err(e) => {
1179                failed += 1;
1180                println!("   {} {}\n", "[ERR]".red(), e);
1181            }
1182        }
1183    }
1184
1185    println!(
1186        "{} Repair complete: {}/{} workspaces, {} compacted, {} index entries synced",
1187        "[OK]".green().bold(),
1188        succeeded.to_string().green(),
1189        ws_with_sessions.len(),
1190        total_compacted.to_string().cyan(),
1191        total_synced.to_string().cyan()
1192    );
1193    if failed > 0 {
1194        println!(
1195            "   {} {} workspace(s) had errors",
1196            "[!]".yellow(),
1197            failed.to_string().red()
1198        );
1199    }
1200
1201    // Reopen VS Code if requested
1202    if reopen && vscode_was_running {
1203        println!("   {} Reopening VS Code...", "[*]".yellow());
1204        reopen_vscode(None)?;
1205        println!(
1206            "   {} VS Code launched. Sessions should now load correctly.",
1207            "[OK]".green()
1208        );
1209    } else if should_close && vscode_was_running {
1210        println!(
1211            "\n{} VS Code was closed. Reopen it to see the repaired sessions.",
1212            "[!]".yellow()
1213        );
1214    }
1215
1216    Ok(())
1217}