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, diagnose_workspace_sessions,
19    get_workspace_storage_db, is_session_file_extension, is_vscode_running, parse_session_file,
20    parse_session_json, read_chat_session_index, register_all_sessions_from_directory,
21    reopen_vscode, repair_workspace_sessions, trim_session_jsonl,
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 session = parse_session_file(&session_file)?;
270
271                    let title = session.title();
272                    let actual_session_id = session
273                        .session_id
274                        .clone()
275                        .unwrap_or_else(|| session_id.to_string());
276
277                    add_session_to_index(
278                        &db_path,
279                        &actual_session_id,
280                        &title,
281                        session.last_message_date,
282                        session.is_imported,
283                        &session.initial_location,
284                        session.is_empty(),
285                    )?;
286
287                    let id_display = if actual_session_id.len() > 12 {
288                        &actual_session_id[..12]
289                    } else {
290                        &actual_session_id
291                    };
292                    println!(
293                        "   {} {} (\"{}\")",
294                        "[OK]".green(),
295                        id_display.cyan(),
296                        title.yellow()
297                    );
298                    registered_count += 1;
299                }
300                Err(e) => {
301                    println!(
302                        "   {} {} - {}",
303                        "[ERR]".red(),
304                        session_id.cyan(),
305                        e.to_string().red()
306                    );
307                }
308            }
309        }
310    }
311
312    println!(
313        "\n{} Registered {} sessions in VS Code's index",
314        "[OK]".green().bold(),
315        registered_count.to_string().cyan()
316    );
317
318    if force && is_vscode_running() {
319        println!(
320            "   {} Sessions should appear in VS Code immediately",
321            "->".cyan()
322        );
323    }
324
325    Ok(())
326}
327
328/// List sessions that exist on disk but are not in VS Code's index
329pub fn list_orphaned(project_path: Option<&str>) -> Result<()> {
330    let path = resolve_path(project_path);
331
332    println!(
333        "{} Finding orphaned sessions for: {}",
334        "[CSM]".cyan().bold(),
335        path.display()
336    );
337
338    // Find the workspace
339    let path_str = path.to_string_lossy().to_string();
340    let (ws_id, ws_path, _folder) = find_workspace_by_path(&path_str)?
341        .ok_or_else(|| CsmError::WorkspaceNotFound(path.display().to_string()))?;
342
343    let chat_sessions_dir = ws_path.join("chatSessions");
344
345    if !chat_sessions_dir.exists() {
346        println!("{} No chatSessions directory found", "[!]".yellow());
347        return Ok(());
348    }
349
350    // Get sessions currently in the index
351    let db_path = get_workspace_storage_db(&ws_id)?;
352    let index = read_chat_session_index(&db_path)?;
353    let indexed_ids: HashSet<String> = index.entries.keys().cloned().collect();
354
355    println!(
356        "   {} sessions currently in VS Code's index",
357        indexed_ids.len().to_string().cyan()
358    );
359
360    // Find sessions on disk
361    let mut orphaned_sessions = Vec::new();
362
363    // Collect files, preferring .jsonl over .json for the same session ID
364    let mut session_files: std::collections::HashMap<String, PathBuf> =
365        std::collections::HashMap::new();
366    for entry in std::fs::read_dir(&chat_sessions_dir)? {
367        let entry = entry?;
368        let path = entry.path();
369        if path
370            .extension()
371            .map(is_session_file_extension)
372            .unwrap_or(false)
373        {
374            if let Some(stem) = path.file_stem() {
375                let stem_str = stem.to_string_lossy().to_string();
376                let is_jsonl = path.extension().is_some_and(|e| e == "jsonl");
377                if !session_files.contains_key(&stem_str) || is_jsonl {
378                    session_files.insert(stem_str, path);
379                }
380            }
381        }
382    }
383
384    for (_, path) in &session_files {
385        if let Ok(session) = parse_session_file(path) {
386            let session_id = session.session_id.clone().unwrap_or_else(|| {
387                path.file_stem()
388                    .map(|s| s.to_string_lossy().to_string())
389                    .unwrap_or_default()
390            });
391
392            if !indexed_ids.contains(&session_id) {
393                let title = session.title();
394                let msg_count = session.requests.len();
395                orphaned_sessions.push((session_id, title, msg_count, path.clone()));
396            }
397        }
398    }
399
400    if orphaned_sessions.is_empty() {
401        println!(
402            "\n{} No orphaned sessions found - all sessions are registered!",
403            "[OK]".green().bold()
404        );
405        return Ok(());
406    }
407
408    println!(
409        "\n{} Found {} orphaned sessions (on disk but not in index):\n",
410        "[!]".yellow().bold(),
411        orphaned_sessions.len().to_string().red()
412    );
413
414    for (session_id, title, msg_count, _path) in &orphaned_sessions {
415        let id_display = if session_id.len() > 12 {
416            &session_id[..12]
417        } else {
418            session_id
419        };
420        println!(
421            "   {} {} ({} messages)",
422            id_display.cyan(),
423            format!("\"{}\"", title).yellow(),
424            msg_count
425        );
426    }
427
428    println!("\n{} To register all orphaned sessions:", "->".cyan());
429    println!("   csm register all --force");
430    println!("\n{} To register specific sessions:", "->".cyan());
431    println!("   csm register session <ID1> <ID2> ... --force");
432
433    Ok(())
434}
435
436/// Count session files in a directory (counts unique session IDs, preferring .jsonl)
437fn count_sessions_in_directory(dir: &PathBuf) -> Result<usize> {
438    let mut session_ids: HashSet<String> = HashSet::new();
439    for entry in std::fs::read_dir(dir)? {
440        let entry = entry?;
441        let path = entry.path();
442        if path
443            .extension()
444            .map(is_session_file_extension)
445            .unwrap_or(false)
446        {
447            if let Some(stem) = path.file_stem() {
448                session_ids.insert(stem.to_string_lossy().to_string());
449            }
450        }
451    }
452    Ok(session_ids.len())
453}
454
455/// Find a session file by ID (supports partial matches, prefers .jsonl over .json)
456fn find_session_file(chat_sessions_dir: &PathBuf, session_id: &str) -> Result<PathBuf> {
457    // First try exact match (.jsonl preferred)
458    let exact_jsonl = chat_sessions_dir.join(format!("{}.jsonl", session_id));
459    if exact_jsonl.exists() {
460        return Ok(exact_jsonl);
461    }
462    let exact_json = chat_sessions_dir.join(format!("{}.json", session_id));
463    if exact_json.exists() {
464        return Ok(exact_json);
465    }
466
467    // Try partial match (prefix), preferring .jsonl
468    let mut best_match: Option<PathBuf> = None;
469    for entry in std::fs::read_dir(chat_sessions_dir)? {
470        let entry = entry?;
471        let path = entry.path();
472
473        if path
474            .extension()
475            .map(is_session_file_extension)
476            .unwrap_or(false)
477        {
478            let filename = path
479                .file_stem()
480                .map(|s| s.to_string_lossy().to_string())
481                .unwrap_or_default();
482
483            if filename.starts_with(session_id) {
484                let is_jsonl = path.extension().is_some_and(|e| e == "jsonl");
485                if best_match.is_none() || is_jsonl {
486                    best_match = Some(path.clone());
487                    if is_jsonl {
488                        return Ok(path);
489                    }
490                }
491                continue;
492            }
493
494            // Also check session_id inside the file
495            if let Ok(session) = parse_session_file(&path) {
496                if let Some(ref sid) = session.session_id {
497                    if sid.starts_with(session_id) || sid == session_id {
498                        let is_jsonl = path.extension().is_some_and(|e| e == "jsonl");
499                        if best_match.is_none() || is_jsonl {
500                            best_match = Some(path.clone());
501                        }
502                    }
503                }
504            }
505        }
506    }
507
508    best_match.ok_or_else(|| CsmError::SessionNotFound(session_id.to_string()).into())
509}
510
511/// Find sessions by title (case-insensitive partial match)
512fn find_sessions_by_titles(
513    chat_sessions_dir: &PathBuf,
514    titles: &[String],
515) -> Result<Vec<(ChatSession, PathBuf)>> {
516    let mut matches = Vec::new();
517    let title_patterns: Vec<String> = titles.iter().map(|t| t.to_lowercase()).collect();
518
519    // Collect files, preferring .jsonl over .json for the same session ID
520    let mut session_files: std::collections::HashMap<String, PathBuf> =
521        std::collections::HashMap::new();
522    for entry in std::fs::read_dir(chat_sessions_dir)? {
523        let entry = entry?;
524        let path = entry.path();
525        if path
526            .extension()
527            .map(is_session_file_extension)
528            .unwrap_or(false)
529        {
530            if let Some(stem) = path.file_stem() {
531                let stem_str = stem.to_string_lossy().to_string();
532                let is_jsonl = path.extension().is_some_and(|e| e == "jsonl");
533                if !session_files.contains_key(&stem_str) || is_jsonl {
534                    session_files.insert(stem_str, path);
535                }
536            }
537        }
538    }
539
540    for (_, path) in &session_files {
541        if let Ok(session) = parse_session_file(path) {
542            let session_title = session.title().to_lowercase();
543
544            for pattern in &title_patterns {
545                if session_title.contains(pattern) {
546                    matches.push((session, path.clone()));
547                    break;
548                }
549            }
550        }
551    }
552
553    if matches.is_empty() {
554        println!(
555            "{} No sessions found matching the specified titles",
556            "[!]".yellow()
557        );
558    }
559
560    Ok(matches)
561}
562
563/// Recursively walk directories and register orphaned sessions for all workspaces found
564pub fn register_recursive(
565    root_path: Option<&str>,
566    max_depth: Option<usize>,
567    force: bool,
568    dry_run: bool,
569    exclude_patterns: &[String],
570) -> Result<()> {
571    let root = resolve_path(root_path);
572
573    println!(
574        "{} Scanning for workspaces recursively from: {}",
575        "[CSM]".cyan().bold(),
576        root.display()
577    );
578
579    if dry_run {
580        println!("{} Dry run mode - no changes will be made", "[!]".yellow());
581    }
582
583    // Check if VS Code is running
584    if !force && !dry_run && is_vscode_running() {
585        println!(
586            "{} VS Code is running. Use {} to register anyway.",
587            "[!]".yellow(),
588            "--force".cyan()
589        );
590        println!("   Note: VS Code uses WAL mode so this is generally safe.");
591        return Err(CsmError::VSCodeRunning.into());
592    }
593
594    // Get all VS Code workspaces
595    let workspaces = discover_workspaces()?;
596    println!(
597        "   Found {} VS Code workspaces to check",
598        workspaces.len().to_string().cyan()
599    );
600
601    // Build a map of normalized project paths to workspace info
602    let mut workspace_map: std::collections::HashMap<String, Vec<&crate::models::Workspace>> =
603        std::collections::HashMap::new();
604    for ws in &workspaces {
605        if let Some(ref project_path) = ws.project_path {
606            let normalized = normalize_path(project_path);
607            workspace_map.entry(normalized).or_default().push(ws);
608        }
609    }
610
611    // Compile exclude patterns
612    let exclude_matchers: Vec<glob::Pattern> = exclude_patterns
613        .iter()
614        .filter_map(|p| glob::Pattern::new(p).ok())
615        .collect();
616
617    // Default exclusions for common non-project directories
618    let default_excludes = [
619        "node_modules",
620        ".git",
621        "target",
622        "build",
623        "dist",
624        ".venv",
625        "venv",
626        "__pycache__",
627        ".cache",
628        "vendor",
629        ".cargo",
630    ];
631
632    let mut total_dirs_scanned = 0;
633    let mut workspaces_found = 0;
634    let mut total_sessions_registered = 0;
635    let mut workspaces_with_orphans: Vec<(String, usize, usize)> = Vec::new();
636
637    // Walk the directory tree
638    walk_directory(
639        &root,
640        &root,
641        0,
642        max_depth,
643        &workspace_map,
644        &exclude_matchers,
645        &default_excludes,
646        force,
647        dry_run,
648        &mut total_dirs_scanned,
649        &mut workspaces_found,
650        &mut total_sessions_registered,
651        &mut workspaces_with_orphans,
652    )?;
653
654    // Print summary
655    println!("\n{}", "═".repeat(60).cyan());
656    println!("{} Recursive scan complete", "[OK]".green().bold());
657    println!("{}", "═".repeat(60).cyan());
658    println!(
659        "   Directories scanned:    {}",
660        total_dirs_scanned.to_string().cyan()
661    );
662    println!(
663        "   Workspaces found:       {}",
664        workspaces_found.to_string().cyan()
665    );
666    println!(
667        "   Sessions registered:    {}",
668        total_sessions_registered.to_string().green()
669    );
670
671    if !workspaces_with_orphans.is_empty() {
672        println!("\n   {} Workspaces with orphaned sessions:", "[+]".green());
673        for (path, orphaned, registered) in &workspaces_with_orphans {
674            let reg_str = if dry_run {
675                format!("would register {}", registered)
676            } else {
677                format!("registered {}", registered)
678            };
679            println!(
680                "      {} ({} orphaned, {})",
681                path.cyan(),
682                orphaned.to_string().yellow(),
683                reg_str.green()
684            );
685        }
686    }
687
688    if total_sessions_registered > 0 && !dry_run {
689        println!(
690            "\n{} VS Code caches the session index in memory.",
691            "[!]".yellow()
692        );
693        println!("   To see the new sessions, do one of the following:");
694        println!(
695            "   * Run: {} (if CSM extension is installed)",
696            "code --command csm.reloadAndShowChats".cyan()
697        );
698        println!(
699            "   * Or press {} in VS Code and run {}",
700            "Ctrl+Shift+P".cyan(),
701            "Developer: Reload Window".cyan()
702        );
703        println!("   * Or restart VS Code");
704    }
705
706    Ok(())
707}
708
709/// Recursively walk a directory and process workspaces
710#[allow(clippy::too_many_arguments)]
711fn walk_directory(
712    current_dir: &Path,
713    root: &Path,
714    current_depth: usize,
715    max_depth: Option<usize>,
716    workspace_map: &std::collections::HashMap<String, Vec<&crate::models::Workspace>>,
717    exclude_matchers: &[glob::Pattern],
718    default_excludes: &[&str],
719    force: bool,
720    dry_run: bool,
721    total_dirs_scanned: &mut usize,
722    workspaces_found: &mut usize,
723    total_sessions_registered: &mut usize,
724    workspaces_with_orphans: &mut Vec<(String, usize, usize)>,
725) -> Result<()> {
726    // Check depth limit
727    if let Some(max) = max_depth {
728        if current_depth > max {
729            return Ok(());
730        }
731    }
732
733    *total_dirs_scanned += 1;
734
735    // Get directory name for exclusion checking
736    let dir_name = current_dir
737        .file_name()
738        .map(|n| n.to_string_lossy().to_string())
739        .unwrap_or_default();
740
741    // Skip default excluded directories
742    if default_excludes.contains(&dir_name.as_str()) {
743        return Ok(());
744    }
745
746    // Skip if matches user exclusion patterns
747    let relative_path = current_dir
748        .strip_prefix(root)
749        .unwrap_or(current_dir)
750        .to_string_lossy();
751    for pattern in exclude_matchers {
752        if pattern.matches(&relative_path) || pattern.matches(&dir_name) {
753            return Ok(());
754        }
755    }
756
757    // Check if this directory is a VS Code workspace
758    let normalized_path = normalize_path(&current_dir.to_string_lossy());
759    if let Some(workspace_entries) = workspace_map.get(&normalized_path) {
760        *workspaces_found += 1;
761
762        for ws in workspace_entries {
763            // Check for orphaned sessions in this workspace
764            if ws.has_chat_sessions {
765                let chat_sessions_dir = &ws.chat_sessions_path;
766
767                // Count orphaned sessions
768                match count_orphaned_sessions(&ws.hash, chat_sessions_dir) {
769                    Ok((on_disk, in_index, orphaned_count)) => {
770                        if orphaned_count > 0 {
771                            let display_path = ws.project_path.as_deref().unwrap_or(&ws.hash);
772
773                            if dry_run {
774                                println!(
775                                    "   {} {} - {} sessions on disk, {} in index, {} orphaned",
776                                    "[DRY]".yellow(),
777                                    display_path.cyan(),
778                                    on_disk.to_string().white(),
779                                    in_index.to_string().white(),
780                                    orphaned_count.to_string().yellow()
781                                );
782                                workspaces_with_orphans.push((
783                                    display_path.to_string(),
784                                    orphaned_count,
785                                    orphaned_count,
786                                ));
787                            } else {
788                                // Register the sessions
789                                match register_all_sessions_from_directory(
790                                    &ws.hash,
791                                    chat_sessions_dir,
792                                    force,
793                                ) {
794                                    Ok(registered) => {
795                                        *total_sessions_registered += registered;
796                                        println!(
797                                            "   {} {} - registered {} sessions",
798                                            "[+]".green(),
799                                            display_path.cyan(),
800                                            registered.to_string().green()
801                                        );
802                                        workspaces_with_orphans.push((
803                                            display_path.to_string(),
804                                            orphaned_count,
805                                            registered,
806                                        ));
807                                    }
808                                    Err(e) => {
809                                        println!(
810                                            "   {} {} - error: {}",
811                                            "[!]".red(),
812                                            display_path.cyan(),
813                                            e
814                                        );
815                                    }
816                                }
817                            }
818                        }
819                    }
820                    Err(e) => {
821                        let display_path = ws.project_path.as_deref().unwrap_or(&ws.hash);
822                        println!(
823                            "   {} {} - error checking: {}",
824                            "[!]".yellow(),
825                            display_path,
826                            e
827                        );
828                    }
829                }
830            }
831        }
832    }
833
834    // Recurse into subdirectories
835    match std::fs::read_dir(current_dir) {
836        Ok(entries) => {
837            for entry in entries.flatten() {
838                let path = entry.path();
839                if path.is_dir() {
840                    // Skip hidden directories
841                    let name = path
842                        .file_name()
843                        .map(|n| n.to_string_lossy().to_string())
844                        .unwrap_or_default();
845                    if name.starts_with('.') {
846                        continue;
847                    }
848
849                    walk_directory(
850                        &path,
851                        root,
852                        current_depth + 1,
853                        max_depth,
854                        workspace_map,
855                        exclude_matchers,
856                        default_excludes,
857                        force,
858                        dry_run,
859                        total_dirs_scanned,
860                        workspaces_found,
861                        total_sessions_registered,
862                        workspaces_with_orphans,
863                    )?;
864                }
865            }
866        }
867        Err(e) => {
868            // Permission denied or other errors - skip silently
869            if e.kind() != std::io::ErrorKind::PermissionDenied {
870                eprintln!(
871                    "   {} Could not read {}: {}",
872                    "[!]".yellow(),
873                    current_dir.display(),
874                    e
875                );
876            }
877        }
878    }
879
880    Ok(())
881}
882
883/// Count orphaned sessions in a workspace (on disk but not in index)
884fn count_orphaned_sessions(
885    workspace_id: &str,
886    chat_sessions_dir: &Path,
887) -> Result<(usize, usize, usize)> {
888    // Get sessions in index
889    let db_path = get_workspace_storage_db(workspace_id)?;
890    let indexed_sessions = read_chat_session_index(&db_path)?;
891    let indexed_ids: HashSet<String> = indexed_sessions.entries.keys().cloned().collect();
892
893    // Count unique sessions on disk (preferring .jsonl over .json)
894    let mut disk_sessions: HashSet<String> = HashSet::new();
895
896    for entry in std::fs::read_dir(chat_sessions_dir)? {
897        let entry = entry?;
898        let path = entry.path();
899
900        if path
901            .extension()
902            .map(is_session_file_extension)
903            .unwrap_or(false)
904        {
905            if let Some(stem) = path.file_stem() {
906                disk_sessions.insert(stem.to_string_lossy().to_string());
907            }
908        }
909    }
910
911    let on_disk = disk_sessions.len();
912    let orphaned = disk_sessions
913        .iter()
914        .filter(|id| !indexed_ids.contains(*id))
915        .count();
916
917    Ok((on_disk, indexed_ids.len(), orphaned))
918}
919
920/// Repair sessions: compact JSONL files and rebuild the index with correct metadata
921pub fn register_repair(
922    project_path: Option<&str>,
923    all: bool,
924    recursive: bool,
925    max_depth: Option<usize>,
926    exclude_patterns: &[String],
927    dry_run: bool,
928    force: bool,
929    close_vscode: bool,
930    reopen: bool,
931) -> Result<()> {
932    if all {
933        return register_repair_all(force, close_vscode, reopen);
934    }
935
936    if recursive {
937        return register_repair_recursive(
938            project_path,
939            max_depth,
940            exclude_patterns,
941            dry_run,
942            force,
943            close_vscode,
944            reopen,
945        );
946    }
947
948    let path = resolve_path(project_path);
949    let should_close = close_vscode || reopen;
950
951    println!(
952        "{} Repairing sessions for: {}",
953        "[CSM]".cyan().bold(),
954        path.display()
955    );
956
957    // Find the workspace
958    let path_str = path.to_string_lossy().to_string();
959    let (ws_id, ws_path, _folder) = find_workspace_by_path(&path_str)?
960        .ok_or_else(|| CsmError::WorkspaceNotFound(path.display().to_string()))?;
961
962    let chat_sessions_dir = ws_path.join("chatSessions");
963
964    if !chat_sessions_dir.exists() {
965        println!(
966            "{} No chatSessions directory found at: {}",
967            "[!]".yellow(),
968            chat_sessions_dir.display()
969        );
970        return Ok(());
971    }
972
973    // Handle VS Code lifecycle
974    let vscode_was_running = is_vscode_running();
975    if vscode_was_running {
976        if should_close {
977            if !confirm_close_vscode(force) {
978                println!("{} Aborted.", "[!]".yellow());
979                return Ok(());
980            }
981            println!("   {} Closing VS Code (saving state)...", "[*]".yellow());
982            close_vscode_and_wait(30)?;
983            println!("   {} VS Code closed.", "[OK]".green());
984        } else if !force {
985            println!(
986                "{} VS Code is running. Its in-memory cache will overwrite index changes.",
987                "[!]".yellow()
988            );
989            println!(
990                "   Use {} to close VS Code first, or {} to force.",
991                "--reopen".cyan(),
992                "--force".cyan()
993            );
994            return Err(CsmError::VSCodeRunning.into());
995        }
996    }
997
998    // Run the repair
999    println!(
1000        "   {} Pass 1: Compacting JSONL files & fixing compat fields...",
1001        "[*]".cyan()
1002    );
1003    println!(
1004        "   {} Pass 1.5: Converting skeleton .json files...",
1005        "[*]".cyan()
1006    );
1007    println!("   {} Pass 2: Fixing cancelled modelState...", "[*]".cyan());
1008    let (compacted, index_fixed) = repair_workspace_sessions(&ws_id, &chat_sessions_dir, true)?;
1009
1010    println!("   {} Pass 3: Index rebuilt.", "[*]".cyan());
1011    println!(
1012        "\n{} Repair complete: {} files compacted, {} index entries synced",
1013        "[OK]".green().bold(),
1014        compacted.to_string().cyan(),
1015        index_fixed.to_string().cyan()
1016    );
1017
1018    // Delete stale .json files when a .jsonl exists for the same session
1019    let mut deleted_json = 0;
1020    if chat_sessions_dir.exists() {
1021        let mut jsonl_sessions: HashSet<String> = HashSet::new();
1022        for entry in std::fs::read_dir(&chat_sessions_dir)? {
1023            let entry = entry?;
1024            let p = entry.path();
1025            if p.extension().is_some_and(|e| e == "jsonl") {
1026                if let Some(stem) = p.file_stem() {
1027                    jsonl_sessions.insert(stem.to_string_lossy().to_string());
1028                }
1029            }
1030        }
1031        for entry in std::fs::read_dir(&chat_sessions_dir)? {
1032            let entry = entry?;
1033            let p = entry.path();
1034            if p.extension().is_some_and(|e| e == "json") {
1035                if let Some(stem) = p.file_stem() {
1036                    if jsonl_sessions.contains(&stem.to_string_lossy().to_string()) {
1037                        // Rename to .json.bak to preserve as backup
1038                        let bak = p.with_extension("json.bak");
1039                        std::fs::rename(&p, &bak)?;
1040                        println!(
1041                            "   {} Backed up stale .json: {} → {}",
1042                            "[*]".yellow(),
1043                            p.file_name().unwrap_or_default().to_string_lossy(),
1044                            bak.file_name().unwrap_or_default().to_string_lossy()
1045                        );
1046                        deleted_json += 1;
1047                    }
1048                }
1049            }
1050        }
1051        if deleted_json > 0 {
1052            // Re-sync index after removing .json files
1053            repair_workspace_sessions(&ws_id, &chat_sessions_dir, true)?;
1054            println!(
1055                "   {} Removed {} stale .json duplicates (backed up as .json.bak)",
1056                "[OK]".green(),
1057                deleted_json
1058            );
1059        }
1060    }
1061
1062    // Reopen VS Code if requested
1063    if reopen && vscode_was_running {
1064        println!("   {} Reopening VS Code...", "[*]".yellow());
1065        reopen_vscode(Some(&path_str))?;
1066        println!(
1067            "   {} VS Code launched. Sessions should now load correctly.",
1068            "[OK]".green()
1069        );
1070    } else if should_close && vscode_was_running {
1071        println!(
1072            "\n{} VS Code was closed. Reopen it to see the repaired sessions.",
1073            "[!]".yellow()
1074        );
1075        println!("   Run: {}", format!("code {}", path.display()).cyan());
1076    }
1077
1078    Ok(())
1079}
1080
1081/// Recursively scan a directory tree for workspaces and repair all discovered sessions
1082fn register_repair_recursive(
1083    root_path: Option<&str>,
1084    max_depth: Option<usize>,
1085    exclude_patterns: &[String],
1086    dry_run: bool,
1087    force: bool,
1088    close_vscode: bool,
1089    reopen: bool,
1090) -> Result<()> {
1091    let root = resolve_path(root_path);
1092    let should_close = close_vscode || reopen;
1093
1094    println!(
1095        "{} Recursively scanning for workspaces to repair from: {}",
1096        "[CSM]".cyan().bold(),
1097        root.display()
1098    );
1099
1100    if dry_run {
1101        println!("{} Dry run mode — no changes will be made", "[!]".yellow());
1102    }
1103
1104    // Handle VS Code lifecycle
1105    let vscode_was_running = is_vscode_running();
1106    if vscode_was_running && !dry_run {
1107        if should_close {
1108            if !confirm_close_vscode(force) {
1109                println!("{} Aborted.", "[!]".yellow());
1110                return Ok(());
1111            }
1112            println!("   {} Closing VS Code (saving state)...", "[*]".yellow());
1113            close_vscode_and_wait(30)?;
1114            println!("   {} VS Code closed.\n", "[OK]".green());
1115        } else if !force {
1116            println!(
1117                "{} VS Code is running. Its in-memory cache will overwrite index changes.",
1118                "[!]".yellow()
1119            );
1120            println!(
1121                "   Use {} to close VS Code first, or {} to force.",
1122                "--reopen".cyan(),
1123                "--force".cyan()
1124            );
1125            return Err(CsmError::VSCodeRunning.into());
1126        }
1127    }
1128
1129    // Get all VS Code workspaces
1130    let workspaces = discover_workspaces()?;
1131    println!(
1132        "   Found {} VS Code workspaces to check",
1133        workspaces.len().to_string().cyan()
1134    );
1135
1136    // Build a map of normalized project paths to workspace info
1137    let mut workspace_map: std::collections::HashMap<String, Vec<&crate::models::Workspace>> =
1138        std::collections::HashMap::new();
1139    for ws in &workspaces {
1140        if let Some(ref project_path) = ws.project_path {
1141            let normalized = normalize_path(project_path);
1142            workspace_map.entry(normalized).or_default().push(ws);
1143        }
1144    }
1145
1146    // Compile exclude patterns
1147    let exclude_matchers: Vec<glob::Pattern> = exclude_patterns
1148        .iter()
1149        .filter_map(|p| glob::Pattern::new(p).ok())
1150        .collect();
1151
1152    let default_excludes = [
1153        "node_modules",
1154        ".git",
1155        "target",
1156        "build",
1157        "dist",
1158        ".venv",
1159        "venv",
1160        "__pycache__",
1161        ".cache",
1162        "vendor",
1163        ".cargo",
1164    ];
1165
1166    let mut total_dirs_scanned = 0usize;
1167    let mut workspaces_found = 0usize;
1168    let mut total_compacted = 0usize;
1169    let mut total_synced = 0usize;
1170    let mut total_issues_found = 0usize;
1171    let mut total_issues_fixed = 0usize;
1172    let mut repair_results: Vec<(String, usize, bool, String)> = Vec::new(); // (path, issues, success, detail)
1173
1174    // Walk the directory tree looking for known workspaces
1175    fn walk_for_repair(
1176        dir: &Path,
1177        root: &Path,
1178        current_depth: usize,
1179        max_depth: Option<usize>,
1180        workspace_map: &std::collections::HashMap<String, Vec<&crate::models::Workspace>>,
1181        exclude_matchers: &[glob::Pattern],
1182        default_excludes: &[&str],
1183        dry_run: bool,
1184        force: bool,
1185        total_dirs_scanned: &mut usize,
1186        workspaces_found: &mut usize,
1187        total_compacted: &mut usize,
1188        total_synced: &mut usize,
1189        total_issues_found: &mut usize,
1190        total_issues_fixed: &mut usize,
1191        repair_results: &mut Vec<(String, usize, bool, String)>,
1192    ) -> Result<()> {
1193        if let Some(max) = max_depth {
1194            if current_depth > max {
1195                return Ok(());
1196            }
1197        }
1198
1199        *total_dirs_scanned += 1;
1200
1201        // Check if this directory is a known workspace
1202        let normalized = normalize_path(&dir.to_string_lossy());
1203        if let Some(ws_list) = workspace_map.get(&normalized) {
1204            for ws in ws_list {
1205                if ws.has_chat_sessions && ws.chat_session_count > 0 {
1206                    *workspaces_found += 1;
1207
1208                    let display_name = ws.project_path.as_deref().unwrap_or(&ws.hash);
1209
1210                    // Diagnose first
1211                    let chat_dir = ws.workspace_path.join("chatSessions");
1212                    match crate::storage::diagnose_workspace_sessions(&ws.hash, &chat_dir) {
1213                        Ok(diag) => {
1214                            let issue_count = diag.issues.len();
1215                            *total_issues_found += issue_count;
1216
1217                            if issue_count == 0 {
1218                                println!(
1219                                    "   {} {} — {} sessions, healthy",
1220                                    "[OK]".green(),
1221                                    display_name.cyan(),
1222                                    ws.chat_session_count
1223                                );
1224                                repair_results.push((
1225                                    display_name.to_string(),
1226                                    0,
1227                                    true,
1228                                    "healthy".to_string(),
1229                                ));
1230                            } else {
1231                                let issue_kinds: Vec<String> = {
1232                                    let mut kinds: Vec<String> = Vec::new();
1233                                    for issue in &diag.issues {
1234                                        let s = format!("{}", issue.kind);
1235                                        if !kinds.contains(&s) {
1236                                            kinds.push(s);
1237                                        }
1238                                    }
1239                                    kinds
1240                                };
1241
1242                                println!(
1243                                    "   {} {} — {} sessions, {} issue(s): {}",
1244                                    "[!]".yellow(),
1245                                    display_name.cyan(),
1246                                    ws.chat_session_count,
1247                                    issue_count,
1248                                    issue_kinds.join(", ")
1249                                );
1250
1251                                if !dry_run {
1252                                    match repair_workspace_sessions(
1253                                        &ws.hash,
1254                                        &chat_dir,
1255                                        force || true,
1256                                    ) {
1257                                        Ok((compacted, synced)) => {
1258                                            *total_compacted += compacted;
1259                                            *total_synced += synced;
1260                                            *total_issues_fixed += issue_count;
1261
1262                                            // Also handle stale .json cleanup
1263                                            let mut deleted_json = 0;
1264                                            let mut jsonl_sessions: HashSet<String> =
1265                                                HashSet::new();
1266                                            if let Ok(entries) = std::fs::read_dir(&chat_dir) {
1267                                                for entry in entries.flatten() {
1268                                                    let p = entry.path();
1269                                                    if p.extension().is_some_and(|e| e == "jsonl") {
1270                                                        if let Some(stem) = p.file_stem() {
1271                                                            jsonl_sessions.insert(
1272                                                                stem.to_string_lossy().to_string(),
1273                                                            );
1274                                                        }
1275                                                    }
1276                                                }
1277                                            }
1278                                            if let Ok(entries) = std::fs::read_dir(&chat_dir) {
1279                                                for entry in entries.flatten() {
1280                                                    let p = entry.path();
1281                                                    if p.extension().is_some_and(|e| e == "json") {
1282                                                        if let Some(stem) = p.file_stem() {
1283                                                            if jsonl_sessions.contains(
1284                                                                &stem.to_string_lossy().to_string(),
1285                                                            ) {
1286                                                                let bak =
1287                                                                    p.with_extension("json.bak");
1288                                                                let _ = std::fs::rename(&p, &bak);
1289                                                                deleted_json += 1;
1290                                                            }
1291                                                        }
1292                                                    }
1293                                                }
1294                                            }
1295                                            if deleted_json > 0 {
1296                                                let _ = repair_workspace_sessions(
1297                                                    &ws.hash, &chat_dir, true,
1298                                                );
1299                                            }
1300
1301                                            let detail = format!(
1302                                                "{} compacted, {} synced{}",
1303                                                compacted,
1304                                                synced,
1305                                                if deleted_json > 0 {
1306                                                    format!(
1307                                                        ", {} stale .json backed up",
1308                                                        deleted_json
1309                                                    )
1310                                                } else {
1311                                                    String::new()
1312                                                }
1313                                            );
1314                                            println!("      {} Fixed: {}", "[OK]".green(), detail);
1315                                            repair_results.push((
1316                                                display_name.to_string(),
1317                                                issue_count,
1318                                                true,
1319                                                detail,
1320                                            ));
1321                                        }
1322                                        Err(e) => {
1323                                            println!("      {} Failed: {}", "[ERR]".red(), e);
1324                                            repair_results.push((
1325                                                display_name.to_string(),
1326                                                issue_count,
1327                                                false,
1328                                                e.to_string(),
1329                                            ));
1330                                        }
1331                                    }
1332                                } else {
1333                                    // Dry run: just list the issues
1334                                    for issue in &diag.issues {
1335                                        println!(
1336                                            "      {} {} — {}",
1337                                            "→".bright_black(),
1338                                            issue.session_id[..8.min(issue.session_id.len())]
1339                                                .to_string(),
1340                                            issue.kind
1341                                        );
1342                                    }
1343                                    repair_results.push((
1344                                        display_name.to_string(),
1345                                        issue_count,
1346                                        true,
1347                                        "dry run".to_string(),
1348                                    ));
1349                                }
1350                            }
1351                        }
1352                        Err(e) => {
1353                            println!("   {} {} — scan failed: {}", "[ERR]".red(), display_name, e);
1354                        }
1355                    }
1356                }
1357            }
1358        }
1359
1360        // Recurse into subdirectories
1361        let entries = match std::fs::read_dir(dir) {
1362            Ok(e) => e,
1363            Err(_) => return Ok(()),
1364        };
1365
1366        for entry in entries {
1367            let entry = match entry {
1368                Ok(e) => e,
1369                Err(_) => continue,
1370            };
1371            let path = entry.path();
1372            if !path.is_dir() {
1373                continue;
1374            }
1375
1376            let dir_name = entry.file_name().to_string_lossy().to_string();
1377
1378            // Skip hidden directories
1379            if dir_name.starts_with('.') {
1380                continue;
1381            }
1382
1383            // Skip default excludes
1384            if default_excludes.iter().any(|e| dir_name == *e) {
1385                continue;
1386            }
1387
1388            // Skip user-specified excludes
1389            if exclude_matchers.iter().any(|p| p.matches(&dir_name)) {
1390                continue;
1391            }
1392
1393            walk_for_repair(
1394                &path,
1395                root,
1396                current_depth + 1,
1397                max_depth,
1398                workspace_map,
1399                exclude_matchers,
1400                default_excludes,
1401                dry_run,
1402                force,
1403                total_dirs_scanned,
1404                workspaces_found,
1405                total_compacted,
1406                total_synced,
1407                total_issues_found,
1408                total_issues_fixed,
1409                repair_results,
1410            )?;
1411        }
1412
1413        Ok(())
1414    }
1415
1416    walk_for_repair(
1417        &root,
1418        &root,
1419        0,
1420        max_depth,
1421        &workspace_map,
1422        &exclude_matchers,
1423        &default_excludes,
1424        dry_run,
1425        force,
1426        &mut total_dirs_scanned,
1427        &mut workspaces_found,
1428        &mut total_compacted,
1429        &mut total_synced,
1430        &mut total_issues_found,
1431        &mut total_issues_fixed,
1432        &mut repair_results,
1433    )?;
1434
1435    // Print summary
1436    println!("\n{}", "═".repeat(60).cyan());
1437    println!("{} Recursive repair scan complete", "[OK]".green().bold());
1438    println!("{}", "═".repeat(60).cyan());
1439    println!(
1440        "   Directories scanned:    {}",
1441        total_dirs_scanned.to_string().cyan()
1442    );
1443    println!(
1444        "   Workspaces found:       {}",
1445        workspaces_found.to_string().cyan()
1446    );
1447    println!(
1448        "   Issues detected:        {}",
1449        if total_issues_found > 0 {
1450            total_issues_found.to_string().yellow()
1451        } else {
1452            total_issues_found.to_string().green()
1453        }
1454    );
1455    if !dry_run {
1456        println!(
1457            "   Issues fixed:           {}",
1458            total_issues_fixed.to_string().green()
1459        );
1460        println!(
1461            "   Files compacted:        {}",
1462            total_compacted.to_string().cyan()
1463        );
1464        println!(
1465            "   Index entries synced:   {}",
1466            total_synced.to_string().cyan()
1467        );
1468    }
1469
1470    let failed_count = repair_results.iter().filter(|(_, _, ok, _)| !ok).count();
1471    if failed_count > 0 {
1472        println!(
1473            "\n   {} {} workspace(s) had repair errors",
1474            "[!]".yellow(),
1475            failed_count.to_string().red()
1476        );
1477    }
1478
1479    // Reopen VS Code if requested
1480    if reopen && vscode_was_running {
1481        println!("   {} Reopening VS Code...", "[*]".yellow());
1482        reopen_vscode(None)?;
1483        println!(
1484            "   {} VS Code launched. Sessions should now load correctly.",
1485            "[OK]".green()
1486        );
1487    } else if should_close && vscode_was_running {
1488        println!(
1489            "\n{} VS Code was closed. Reopen it to see the repaired sessions.",
1490            "[!]".yellow()
1491        );
1492    }
1493
1494    Ok(())
1495}
1496
1497/// Repair all workspaces that have chat sessions
1498fn register_repair_all(force: bool, close_vscode: bool, reopen: bool) -> Result<()> {
1499    let should_close = close_vscode || reopen;
1500
1501    println!(
1502        "{} Repairing all workspaces with chat sessions...\n",
1503        "[CSM]".cyan().bold(),
1504    );
1505
1506    // Handle VS Code lifecycle once for all workspaces
1507    let vscode_was_running = is_vscode_running();
1508    if vscode_was_running {
1509        if should_close {
1510            if !confirm_close_vscode(force) {
1511                println!("{} Aborted.", "[!]".yellow());
1512                return Ok(());
1513            }
1514            println!("   {} Closing VS Code (saving state)...", "[*]".yellow());
1515            close_vscode_and_wait(30)?;
1516            println!("   {} VS Code closed.\n", "[OK]".green());
1517        } else if !force {
1518            println!(
1519                "{} VS Code is running. Its in-memory cache will overwrite index changes.",
1520                "[!]".yellow()
1521            );
1522            println!(
1523                "   Use {} to close VS Code first, or {} to force.",
1524                "--reopen".cyan(),
1525                "--force".cyan()
1526            );
1527            return Err(CsmError::VSCodeRunning.into());
1528        }
1529    }
1530
1531    let workspaces = discover_workspaces()?;
1532    let ws_with_sessions: Vec<_> = workspaces
1533        .iter()
1534        .filter(|w| w.has_chat_sessions && w.chat_session_count > 0)
1535        .collect();
1536
1537    if ws_with_sessions.is_empty() {
1538        println!("{} No workspaces with chat sessions found.", "[!]".yellow());
1539        return Ok(());
1540    }
1541
1542    println!(
1543        "   Found {} workspaces with chat sessions\n",
1544        ws_with_sessions.len().to_string().cyan()
1545    );
1546
1547    let mut total_compacted = 0usize;
1548    let mut total_synced = 0usize;
1549    let mut succeeded = 0usize;
1550    let mut failed = 0usize;
1551
1552    for (i, ws) in ws_with_sessions.iter().enumerate() {
1553        let display_name = ws.project_path.as_deref().unwrap_or(&ws.hash);
1554        println!(
1555            "[{}/{}] {} {}",
1556            i + 1,
1557            ws_with_sessions.len(),
1558            "===".dimmed(),
1559            display_name.cyan()
1560        );
1561
1562        let chat_sessions_dir = ws.workspace_path.join("chatSessions");
1563        if !chat_sessions_dir.exists() {
1564            println!(
1565                "   {} No chatSessions directory, skipping.\n",
1566                "[!]".yellow()
1567            );
1568            continue;
1569        }
1570
1571        match repair_workspace_sessions(&ws.hash, &chat_sessions_dir, true) {
1572            Ok((compacted, index_fixed)) => {
1573                // Delete stale .json files when a .jsonl exists for the same session
1574                let mut deleted_json = 0;
1575                let mut jsonl_sessions: HashSet<String> = HashSet::new();
1576                for entry in std::fs::read_dir(&chat_sessions_dir)? {
1577                    let entry = entry?;
1578                    let p = entry.path();
1579                    if p.extension().is_some_and(|e| e == "jsonl") {
1580                        if let Some(stem) = p.file_stem() {
1581                            jsonl_sessions.insert(stem.to_string_lossy().to_string());
1582                        }
1583                    }
1584                }
1585                for entry in std::fs::read_dir(&chat_sessions_dir)? {
1586                    let entry = entry?;
1587                    let p = entry.path();
1588                    if p.extension().is_some_and(|e| e == "json") {
1589                        if let Some(stem) = p.file_stem() {
1590                            if jsonl_sessions.contains(&stem.to_string_lossy().to_string()) {
1591                                let bak = p.with_extension("json.bak");
1592                                std::fs::rename(&p, &bak)?;
1593                                deleted_json += 1;
1594                            }
1595                        }
1596                    }
1597                }
1598                if deleted_json > 0 {
1599                    repair_workspace_sessions(&ws.hash, &chat_sessions_dir, true)?;
1600                }
1601
1602                total_compacted += compacted;
1603                total_synced += index_fixed;
1604                succeeded += 1;
1605                println!(
1606                    "   {} {} compacted, {} synced{}\n",
1607                    "[OK]".green(),
1608                    compacted,
1609                    index_fixed,
1610                    if deleted_json > 0 {
1611                        format!(", {} stale .json backed up", deleted_json)
1612                    } else {
1613                        String::new()
1614                    }
1615                );
1616            }
1617            Err(e) => {
1618                failed += 1;
1619                println!("   {} {}\n", "[ERR]".red(), e);
1620            }
1621        }
1622    }
1623
1624    println!(
1625        "{} Repair complete: {}/{} workspaces, {} compacted, {} index entries synced",
1626        "[OK]".green().bold(),
1627        succeeded.to_string().green(),
1628        ws_with_sessions.len(),
1629        total_compacted.to_string().cyan(),
1630        total_synced.to_string().cyan()
1631    );
1632    if failed > 0 {
1633        println!(
1634            "   {} {} workspace(s) had errors",
1635            "[!]".yellow(),
1636            failed.to_string().red()
1637        );
1638    }
1639
1640    // Reopen VS Code if requested
1641    if reopen && vscode_was_running {
1642        println!("   {} Reopening VS Code...", "[*]".yellow());
1643        reopen_vscode(None)?;
1644        println!(
1645            "   {} VS Code launched. Sessions should now load correctly.",
1646            "[OK]".green()
1647        );
1648    } else if should_close && vscode_was_running {
1649        println!(
1650            "\n{} VS Code was closed. Reopen it to see the repaired sessions.",
1651            "[!]".yellow()
1652        );
1653    }
1654
1655    Ok(())
1656}
1657
1658/// Trim oversized sessions by keeping only the most recent requests.
1659///
1660/// Very long chat sessions (100+ requests) can grow to 50-100+ MB, which causes
1661/// VS Code to fail loading them. This command trims the requests array to keep
1662/// only the most recent N entries, dramatically reducing file size. The full
1663/// session is preserved as a `.jsonl.bak` backup.
1664pub fn register_trim(
1665    project_path: Option<&str>,
1666    keep: usize,
1667    session_id: Option<&str>,
1668    all: bool,
1669    threshold_mb: u64,
1670    force: bool,
1671) -> Result<()> {
1672    let path = resolve_path(project_path);
1673
1674    println!(
1675        "{} Trimming oversized sessions for: {}",
1676        "[CSM]".cyan().bold(),
1677        path.display()
1678    );
1679
1680    // Find the workspace
1681    let path_str = path.to_string_lossy().to_string();
1682    let (ws_id, ws_path, _folder) = find_workspace_by_path(&path_str)?
1683        .ok_or_else(|| CsmError::WorkspaceNotFound(path.display().to_string()))?;
1684
1685    let chat_sessions_dir = ws_path.join("chatSessions");
1686
1687    if !chat_sessions_dir.exists() {
1688        println!(
1689            "{} No chatSessions directory found at: {}",
1690            "[!]".yellow(),
1691            chat_sessions_dir.display()
1692        );
1693        return Ok(());
1694    }
1695
1696    // Check VS Code
1697    if !force && is_vscode_running() {
1698        println!(
1699            "{} VS Code is running. Use {} to force.",
1700            "[!]".yellow(),
1701            "--force".cyan()
1702        );
1703        return Err(CsmError::VSCodeRunning.into());
1704    }
1705
1706    let mut trimmed_count = 0;
1707
1708    if let Some(sid) = session_id {
1709        // Trim a specific session
1710        let jsonl_path = chat_sessions_dir.join(format!("{}.jsonl", sid));
1711        if !jsonl_path.exists() {
1712            return Err(
1713                CsmError::InvalidSessionFormat(format!("Session not found: {}", sid)).into(),
1714            );
1715        }
1716
1717        let size_mb = std::fs::metadata(&jsonl_path)?.len() / (1024 * 1024);
1718        println!(
1719            "   {} Trimming {} ({}MB, keeping last {} requests)...",
1720            "[*]".cyan(),
1721            sid,
1722            size_mb,
1723            keep
1724        );
1725
1726        match trim_session_jsonl(&jsonl_path, keep) {
1727            Ok((orig, kept, orig_mb, new_mb)) => {
1728                println!(
1729                    "   {} Trimmed: {} → {} requests, {:.1}MB → {:.1}MB",
1730                    "[OK]".green(),
1731                    orig,
1732                    kept,
1733                    orig_mb,
1734                    new_mb
1735                );
1736                trimmed_count += 1;
1737            }
1738            Err(e) => {
1739                println!("   {} Failed to trim {}: {}", "[ERR]".red(), sid, e);
1740            }
1741        }
1742    } else if all {
1743        // Trim all sessions over the threshold
1744        for entry in std::fs::read_dir(&chat_sessions_dir)? {
1745            let entry = entry?;
1746            let p = entry.path();
1747            if p.extension().is_some_and(|e| e == "jsonl") {
1748                let size = std::fs::metadata(&p)?.len();
1749                let size_mb_val = size / (1024 * 1024);
1750
1751                if size_mb_val >= threshold_mb {
1752                    let stem = p
1753                        .file_stem()
1754                        .map(|s| s.to_string_lossy().to_string())
1755                        .unwrap_or_default();
1756                    println!(
1757                        "   {} Trimming {} ({}MB, keeping last {} requests)...",
1758                        "[*]".cyan(),
1759                        stem,
1760                        size_mb_val,
1761                        keep
1762                    );
1763
1764                    match trim_session_jsonl(&p, keep) {
1765                        Ok((orig, kept, orig_mb, new_mb)) => {
1766                            println!(
1767                                "   {} Trimmed: {} → {} requests, {:.1}MB → {:.1}MB",
1768                                "[OK]".green(),
1769                                orig,
1770                                kept,
1771                                orig_mb,
1772                                new_mb
1773                            );
1774                            trimmed_count += 1;
1775                        }
1776                        Err(e) => {
1777                            println!("   {} Failed to trim {}: {}", "[WARN]".yellow(), stem, e);
1778                        }
1779                    }
1780                }
1781            }
1782        }
1783    } else {
1784        // Auto-detect: find the largest session over the threshold
1785        let mut largest: Option<(PathBuf, u64)> = None;
1786
1787        for entry in std::fs::read_dir(&chat_sessions_dir)? {
1788            let entry = entry?;
1789            let p = entry.path();
1790            if p.extension().is_some_and(|e| e == "jsonl") {
1791                let size = std::fs::metadata(&p)?.len();
1792                let size_mb_val = size / (1024 * 1024);
1793
1794                if size_mb_val >= threshold_mb {
1795                    if largest.as_ref().map_or(true, |(_, s)| size > *s) {
1796                        largest = Some((p, size));
1797                    }
1798                }
1799            }
1800        }
1801
1802        match largest {
1803            Some((p, size)) => {
1804                let stem = p
1805                    .file_stem()
1806                    .map(|s| s.to_string_lossy().to_string())
1807                    .unwrap_or_default();
1808                let size_mb_val = size / (1024 * 1024);
1809                println!(
1810                    "   {} Trimming largest session: {} ({}MB, keeping last {} requests)...",
1811                    "[*]".cyan(),
1812                    stem,
1813                    size_mb_val,
1814                    keep
1815                );
1816
1817                match trim_session_jsonl(&p, keep) {
1818                    Ok((orig, kept, orig_mb, new_mb)) => {
1819                        println!(
1820                            "   {} Trimmed: {} → {} requests, {:.1}MB → {:.1}MB",
1821                            "[OK]".green(),
1822                            orig,
1823                            kept,
1824                            orig_mb,
1825                            new_mb
1826                        );
1827                        trimmed_count += 1;
1828                    }
1829                    Err(e) => {
1830                        println!("   {} Failed to trim: {}", "[ERR]".red(), e);
1831                    }
1832                }
1833            }
1834            None => {
1835                println!(
1836                    "   {} No sessions found over {}MB threshold. Use {} to lower the threshold.",
1837                    "[*]".cyan(),
1838                    threshold_mb,
1839                    "--threshold-mb".cyan()
1840                );
1841            }
1842        }
1843    }
1844
1845    if trimmed_count > 0 {
1846        // Re-sync the index
1847        let _ = repair_workspace_sessions(&ws_id, &chat_sessions_dir, true);
1848        println!(
1849            "\n{} Trim complete: {} session(s) trimmed. Full history backed up as .jsonl.bak",
1850            "[OK]".green().bold(),
1851            trimmed_count.to_string().cyan()
1852        );
1853    }
1854
1855    Ok(())
1856}