1use 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
25fn 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
43pub 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
54pub 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 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 let path_str = path.to_string_lossy().to_string();
75 return crate::commands::history_merge(
76 Some(&path_str),
77 None, force, false, );
81 }
82
83 println!(
84 "{} Registering all sessions for: {}",
85 "[CSM]".cyan().bold(),
86 path.display()
87 );
88
89 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 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 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 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 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 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
185pub 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 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 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 let db_path = get_workspace_storage_db(&ws_id)?;
213
214 let mut registered_count = 0;
215
216 if let Some(titles) = titles {
217 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 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
329pub 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 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 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 let mut orphaned_sessions = Vec::new();
363
364 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
437fn 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
456fn find_session_file(chat_sessions_dir: &PathBuf, session_id: &str) -> Result<PathBuf> {
458 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 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 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
512fn 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 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
564pub 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 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 let workspaces = discover_workspaces()?;
597 println!(
598 " Found {} VS Code workspaces to check",
599 workspaces.len().to_string().cyan()
600 );
601
602 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 let exclude_matchers: Vec<glob::Pattern> = exclude_patterns
614 .iter()
615 .filter_map(|p| glob::Pattern::new(p).ok())
616 .collect();
617
618 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_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 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#[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 if let Some(max) = max_depth {
729 if current_depth > max {
730 return Ok(());
731 }
732 }
733
734 *total_dirs_scanned += 1;
735
736 let dir_name = current_dir
738 .file_name()
739 .map(|n| n.to_string_lossy().to_string())
740 .unwrap_or_default();
741
742 if default_excludes.contains(&dir_name.as_str()) {
744 return Ok(());
745 }
746
747 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 let normalized_path = normalize_path(¤t_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 if ws.has_chat_sessions {
766 let chat_sessions_dir = &ws.chat_sessions_path;
767
768 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 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 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 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 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
884fn count_orphaned_sessions(
886 workspace_id: &str,
887 chat_sessions_dir: &Path,
888) -> Result<(usize, usize, usize)> {
889 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 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
921pub 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 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 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 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 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 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 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 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
1058fn 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 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 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 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}