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, 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,
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 recursive: bool,
926 max_depth: Option<usize>,
927 exclude_patterns: &[String],
928 dry_run: bool,
929 force: bool,
930 close_vscode: bool,
931 reopen: bool,
932) -> Result<()> {
933 if all {
934 return register_repair_all(force, close_vscode, reopen);
935 }
936
937 if recursive {
938 return register_repair_recursive(
939 project_path,
940 max_depth,
941 exclude_patterns,
942 dry_run,
943 force,
944 close_vscode,
945 reopen,
946 );
947 }
948
949 let path = resolve_path(project_path);
950 let should_close = close_vscode || reopen;
951
952 println!(
953 "{} Repairing sessions for: {}",
954 "[CSM]".cyan().bold(),
955 path.display()
956 );
957
958 let path_str = path.to_string_lossy().to_string();
960 let (ws_id, ws_path, _folder) = find_workspace_by_path(&path_str)?
961 .ok_or_else(|| CsmError::WorkspaceNotFound(path.display().to_string()))?;
962
963 let chat_sessions_dir = ws_path.join("chatSessions");
964
965 if !chat_sessions_dir.exists() {
966 println!(
967 "{} No chatSessions directory found at: {}",
968 "[!]".yellow(),
969 chat_sessions_dir.display()
970 );
971 return Ok(());
972 }
973
974 let vscode_was_running = is_vscode_running();
976 if vscode_was_running {
977 if should_close {
978 if !confirm_close_vscode(force) {
979 println!("{} Aborted.", "[!]".yellow());
980 return Ok(());
981 }
982 println!(" {} Closing VS Code (saving state)...", "[*]".yellow());
983 close_vscode_and_wait(30)?;
984 println!(" {} VS Code closed.", "[OK]".green());
985 } else if !force {
986 println!(
987 "{} VS Code is running. Its in-memory cache will overwrite index changes.",
988 "[!]".yellow()
989 );
990 println!(
991 " Use {} to close VS Code first, or {} to force.",
992 "--reopen".cyan(),
993 "--force".cyan()
994 );
995 return Err(CsmError::VSCodeRunning.into());
996 }
997 }
998
999 println!(" {} Pass 1: Compacting JSONL files...", "[*]".cyan());
1001 let (compacted, index_fixed) = repair_workspace_sessions(&ws_id, &chat_sessions_dir, true)?;
1002
1003 println!(" {} Pass 2: Index rebuilt.", "[*]".cyan());
1004 println!(
1005 "\n{} Repair complete: {} files compacted, {} index entries synced",
1006 "[OK]".green().bold(),
1007 compacted.to_string().cyan(),
1008 index_fixed.to_string().cyan()
1009 );
1010
1011 let mut deleted_json = 0;
1013 if chat_sessions_dir.exists() {
1014 let mut jsonl_sessions: HashSet<String> = HashSet::new();
1015 for entry in std::fs::read_dir(&chat_sessions_dir)? {
1016 let entry = entry?;
1017 let p = entry.path();
1018 if p.extension().is_some_and(|e| e == "jsonl") {
1019 if let Some(stem) = p.file_stem() {
1020 jsonl_sessions.insert(stem.to_string_lossy().to_string());
1021 }
1022 }
1023 }
1024 for entry in std::fs::read_dir(&chat_sessions_dir)? {
1025 let entry = entry?;
1026 let p = entry.path();
1027 if p.extension().is_some_and(|e| e == "json") {
1028 if let Some(stem) = p.file_stem() {
1029 if jsonl_sessions.contains(&stem.to_string_lossy().to_string()) {
1030 let bak = p.with_extension("json.bak");
1032 std::fs::rename(&p, &bak)?;
1033 println!(
1034 " {} Backed up stale .json: {} → {}",
1035 "[*]".yellow(),
1036 p.file_name().unwrap_or_default().to_string_lossy(),
1037 bak.file_name().unwrap_or_default().to_string_lossy()
1038 );
1039 deleted_json += 1;
1040 }
1041 }
1042 }
1043 }
1044 if deleted_json > 0 {
1045 repair_workspace_sessions(&ws_id, &chat_sessions_dir, true)?;
1047 println!(
1048 " {} Removed {} stale .json duplicates (backed up as .json.bak)",
1049 "[OK]".green(),
1050 deleted_json
1051 );
1052 }
1053 }
1054
1055 if reopen && vscode_was_running {
1057 println!(" {} Reopening VS Code...", "[*]".yellow());
1058 reopen_vscode(Some(&path_str))?;
1059 println!(
1060 " {} VS Code launched. Sessions should now load correctly.",
1061 "[OK]".green()
1062 );
1063 } else if should_close && vscode_was_running {
1064 println!(
1065 "\n{} VS Code was closed. Reopen it to see the repaired sessions.",
1066 "[!]".yellow()
1067 );
1068 println!(" Run: {}", format!("code {}", path.display()).cyan());
1069 }
1070
1071 Ok(())
1072}
1073
1074fn register_repair_recursive(
1076 root_path: Option<&str>,
1077 max_depth: Option<usize>,
1078 exclude_patterns: &[String],
1079 dry_run: bool,
1080 force: bool,
1081 close_vscode: bool,
1082 reopen: bool,
1083) -> Result<()> {
1084 let root = resolve_path(root_path);
1085 let should_close = close_vscode || reopen;
1086
1087 println!(
1088 "{} Recursively scanning for workspaces to repair from: {}",
1089 "[CSM]".cyan().bold(),
1090 root.display()
1091 );
1092
1093 if dry_run {
1094 println!("{} Dry run mode — no changes will be made", "[!]".yellow());
1095 }
1096
1097 let vscode_was_running = is_vscode_running();
1099 if vscode_was_running && !dry_run {
1100 if should_close {
1101 if !confirm_close_vscode(force) {
1102 println!("{} Aborted.", "[!]".yellow());
1103 return Ok(());
1104 }
1105 println!(" {} Closing VS Code (saving state)...", "[*]".yellow());
1106 close_vscode_and_wait(30)?;
1107 println!(" {} VS Code closed.\n", "[OK]".green());
1108 } else if !force {
1109 println!(
1110 "{} VS Code is running. Its in-memory cache will overwrite index changes.",
1111 "[!]".yellow()
1112 );
1113 println!(
1114 " Use {} to close VS Code first, or {} to force.",
1115 "--reopen".cyan(),
1116 "--force".cyan()
1117 );
1118 return Err(CsmError::VSCodeRunning.into());
1119 }
1120 }
1121
1122 let workspaces = discover_workspaces()?;
1124 println!(
1125 " Found {} VS Code workspaces to check",
1126 workspaces.len().to_string().cyan()
1127 );
1128
1129 let mut workspace_map: std::collections::HashMap<String, Vec<&crate::models::Workspace>> =
1131 std::collections::HashMap::new();
1132 for ws in &workspaces {
1133 if let Some(ref project_path) = ws.project_path {
1134 let normalized = normalize_path(project_path);
1135 workspace_map.entry(normalized).or_default().push(ws);
1136 }
1137 }
1138
1139 let exclude_matchers: Vec<glob::Pattern> = exclude_patterns
1141 .iter()
1142 .filter_map(|p| glob::Pattern::new(p).ok())
1143 .collect();
1144
1145 let default_excludes = [
1146 "node_modules",
1147 ".git",
1148 "target",
1149 "build",
1150 "dist",
1151 ".venv",
1152 "venv",
1153 "__pycache__",
1154 ".cache",
1155 "vendor",
1156 ".cargo",
1157 ];
1158
1159 let mut total_dirs_scanned = 0usize;
1160 let mut workspaces_found = 0usize;
1161 let mut total_compacted = 0usize;
1162 let mut total_synced = 0usize;
1163 let mut total_issues_found = 0usize;
1164 let mut total_issues_fixed = 0usize;
1165 let mut repair_results: Vec<(String, usize, bool, String)> = Vec::new(); fn walk_for_repair(
1169 dir: &Path,
1170 root: &Path,
1171 current_depth: usize,
1172 max_depth: Option<usize>,
1173 workspace_map: &std::collections::HashMap<String, Vec<&crate::models::Workspace>>,
1174 exclude_matchers: &[glob::Pattern],
1175 default_excludes: &[&str],
1176 dry_run: bool,
1177 force: bool,
1178 total_dirs_scanned: &mut usize,
1179 workspaces_found: &mut usize,
1180 total_compacted: &mut usize,
1181 total_synced: &mut usize,
1182 total_issues_found: &mut usize,
1183 total_issues_fixed: &mut usize,
1184 repair_results: &mut Vec<(String, usize, bool, String)>,
1185 ) -> Result<()> {
1186 if let Some(max) = max_depth {
1187 if current_depth > max {
1188 return Ok(());
1189 }
1190 }
1191
1192 *total_dirs_scanned += 1;
1193
1194 let normalized = normalize_path(&dir.to_string_lossy());
1196 if let Some(ws_list) = workspace_map.get(&normalized) {
1197 for ws in ws_list {
1198 if ws.has_chat_sessions && ws.chat_session_count > 0 {
1199 *workspaces_found += 1;
1200
1201 let display_name = ws
1202 .project_path
1203 .as_deref()
1204 .unwrap_or(&ws.hash);
1205
1206 let chat_dir = ws.workspace_path.join("chatSessions");
1208 match crate::storage::diagnose_workspace_sessions(&ws.hash, &chat_dir) {
1209 Ok(diag) => {
1210 let issue_count = diag.issues.len();
1211 *total_issues_found += issue_count;
1212
1213 if issue_count == 0 {
1214 println!(
1215 " {} {} — {} sessions, healthy",
1216 "[OK]".green(),
1217 display_name.cyan(),
1218 ws.chat_session_count
1219 );
1220 repair_results.push((
1221 display_name.to_string(),
1222 0,
1223 true,
1224 "healthy".to_string(),
1225 ));
1226 } else {
1227 let issue_kinds: Vec<String> = {
1228 let mut kinds: Vec<String> = Vec::new();
1229 for issue in &diag.issues {
1230 let s = format!("{}", issue.kind);
1231 if !kinds.contains(&s) {
1232 kinds.push(s);
1233 }
1234 }
1235 kinds
1236 };
1237
1238 println!(
1239 " {} {} — {} sessions, {} issue(s): {}",
1240 "[!]".yellow(),
1241 display_name.cyan(),
1242 ws.chat_session_count,
1243 issue_count,
1244 issue_kinds.join(", ")
1245 );
1246
1247 if !dry_run {
1248 match repair_workspace_sessions(
1249 &ws.hash, &chat_dir, force || true,
1250 ) {
1251 Ok((compacted, synced)) => {
1252 *total_compacted += compacted;
1253 *total_synced += synced;
1254 *total_issues_fixed += issue_count;
1255
1256 let mut deleted_json = 0;
1258 let mut jsonl_sessions: HashSet<String> =
1259 HashSet::new();
1260 if let Ok(entries) = std::fs::read_dir(&chat_dir) {
1261 for entry in entries.flatten() {
1262 let p = entry.path();
1263 if p.extension()
1264 .is_some_and(|e| e == "jsonl")
1265 {
1266 if let Some(stem) = p.file_stem() {
1267 jsonl_sessions.insert(
1268 stem.to_string_lossy().to_string(),
1269 );
1270 }
1271 }
1272 }
1273 }
1274 if let Ok(entries) = std::fs::read_dir(&chat_dir) {
1275 for entry in entries.flatten() {
1276 let p = entry.path();
1277 if p.extension().is_some_and(|e| e == "json")
1278 {
1279 if let Some(stem) = p.file_stem() {
1280 if jsonl_sessions.contains(
1281 &stem.to_string_lossy().to_string(),
1282 ) {
1283 let bak =
1284 p.with_extension("json.bak");
1285 let _ = std::fs::rename(&p, &bak);
1286 deleted_json += 1;
1287 }
1288 }
1289 }
1290 }
1291 }
1292 if deleted_json > 0 {
1293 let _ = repair_workspace_sessions(
1294 &ws.hash,
1295 &chat_dir,
1296 true,
1297 );
1298 }
1299
1300 let detail = format!(
1301 "{} compacted, {} synced{}",
1302 compacted,
1303 synced,
1304 if deleted_json > 0 {
1305 format!(
1306 ", {} stale .json backed up",
1307 deleted_json
1308 )
1309 } else {
1310 String::new()
1311 }
1312 );
1313 println!(
1314 " {} Fixed: {}",
1315 "[OK]".green(),
1316 detail
1317 );
1318 repair_results.push((
1319 display_name.to_string(),
1320 issue_count,
1321 true,
1322 detail,
1323 ));
1324 }
1325 Err(e) => {
1326 println!(
1327 " {} Failed: {}",
1328 "[ERR]".red(),
1329 e
1330 );
1331 repair_results.push((
1332 display_name.to_string(),
1333 issue_count,
1334 false,
1335 e.to_string(),
1336 ));
1337 }
1338 }
1339 } else {
1340 for issue in &diag.issues {
1342 println!(
1343 " {} {} — {}",
1344 "→".bright_black(),
1345 issue.session_id[..8.min(issue.session_id.len())]
1346 .to_string(),
1347 issue.kind
1348 );
1349 }
1350 repair_results.push((
1351 display_name.to_string(),
1352 issue_count,
1353 true,
1354 "dry run".to_string(),
1355 ));
1356 }
1357 }
1358 }
1359 Err(e) => {
1360 println!(
1361 " {} {} — scan failed: {}",
1362 "[ERR]".red(),
1363 display_name,
1364 e
1365 );
1366 }
1367 }
1368 }
1369 }
1370 }
1371
1372 let entries = match std::fs::read_dir(dir) {
1374 Ok(e) => e,
1375 Err(_) => return Ok(()),
1376 };
1377
1378 for entry in entries {
1379 let entry = match entry {
1380 Ok(e) => e,
1381 Err(_) => continue,
1382 };
1383 let path = entry.path();
1384 if !path.is_dir() {
1385 continue;
1386 }
1387
1388 let dir_name = entry.file_name().to_string_lossy().to_string();
1389
1390 if dir_name.starts_with('.') {
1392 continue;
1393 }
1394
1395 if default_excludes.iter().any(|e| dir_name == *e) {
1397 continue;
1398 }
1399
1400 if exclude_matchers
1402 .iter()
1403 .any(|p| p.matches(&dir_name))
1404 {
1405 continue;
1406 }
1407
1408 walk_for_repair(
1409 &path,
1410 root,
1411 current_depth + 1,
1412 max_depth,
1413 workspace_map,
1414 exclude_matchers,
1415 default_excludes,
1416 dry_run,
1417 force,
1418 total_dirs_scanned,
1419 workspaces_found,
1420 total_compacted,
1421 total_synced,
1422 total_issues_found,
1423 total_issues_fixed,
1424 repair_results,
1425 )?;
1426 }
1427
1428 Ok(())
1429 }
1430
1431 walk_for_repair(
1432 &root,
1433 &root,
1434 0,
1435 max_depth,
1436 &workspace_map,
1437 &exclude_matchers,
1438 &default_excludes,
1439 dry_run,
1440 force,
1441 &mut total_dirs_scanned,
1442 &mut workspaces_found,
1443 &mut total_compacted,
1444 &mut total_synced,
1445 &mut total_issues_found,
1446 &mut total_issues_fixed,
1447 &mut repair_results,
1448 )?;
1449
1450 println!("\n{}", "═".repeat(60).cyan());
1452 println!("{} Recursive repair scan complete", "[OK]".green().bold());
1453 println!("{}", "═".repeat(60).cyan());
1454 println!(
1455 " Directories scanned: {}",
1456 total_dirs_scanned.to_string().cyan()
1457 );
1458 println!(
1459 " Workspaces found: {}",
1460 workspaces_found.to_string().cyan()
1461 );
1462 println!(
1463 " Issues detected: {}",
1464 if total_issues_found > 0 {
1465 total_issues_found.to_string().yellow()
1466 } else {
1467 total_issues_found.to_string().green()
1468 }
1469 );
1470 if !dry_run {
1471 println!(
1472 " Issues fixed: {}",
1473 total_issues_fixed.to_string().green()
1474 );
1475 println!(
1476 " Files compacted: {}",
1477 total_compacted.to_string().cyan()
1478 );
1479 println!(
1480 " Index entries synced: {}",
1481 total_synced.to_string().cyan()
1482 );
1483 }
1484
1485 let failed_count = repair_results.iter().filter(|(_, _, ok, _)| !ok).count();
1486 if failed_count > 0 {
1487 println!(
1488 "\n {} {} workspace(s) had repair errors",
1489 "[!]".yellow(),
1490 failed_count.to_string().red()
1491 );
1492 }
1493
1494 if reopen && vscode_was_running {
1496 println!(" {} Reopening VS Code...", "[*]".yellow());
1497 reopen_vscode(None)?;
1498 println!(
1499 " {} VS Code launched. Sessions should now load correctly.",
1500 "[OK]".green()
1501 );
1502 } else if should_close && vscode_was_running {
1503 println!(
1504 "\n{} VS Code was closed. Reopen it to see the repaired sessions.",
1505 "[!]".yellow()
1506 );
1507 }
1508
1509 Ok(())
1510}
1511
1512fn register_repair_all(force: bool, close_vscode: bool, reopen: bool) -> Result<()> {
1514 let should_close = close_vscode || reopen;
1515
1516 println!(
1517 "{} Repairing all workspaces with chat sessions...\n",
1518 "[CSM]".cyan().bold(),
1519 );
1520
1521 let vscode_was_running = is_vscode_running();
1523 if vscode_was_running {
1524 if should_close {
1525 if !confirm_close_vscode(force) {
1526 println!("{} Aborted.", "[!]".yellow());
1527 return Ok(());
1528 }
1529 println!(" {} Closing VS Code (saving state)...", "[*]".yellow());
1530 close_vscode_and_wait(30)?;
1531 println!(" {} VS Code closed.\n", "[OK]".green());
1532 } else if !force {
1533 println!(
1534 "{} VS Code is running. Its in-memory cache will overwrite index changes.",
1535 "[!]".yellow()
1536 );
1537 println!(
1538 " Use {} to close VS Code first, or {} to force.",
1539 "--reopen".cyan(),
1540 "--force".cyan()
1541 );
1542 return Err(CsmError::VSCodeRunning.into());
1543 }
1544 }
1545
1546 let workspaces = discover_workspaces()?;
1547 let ws_with_sessions: Vec<_> = workspaces
1548 .iter()
1549 .filter(|w| w.has_chat_sessions && w.chat_session_count > 0)
1550 .collect();
1551
1552 if ws_with_sessions.is_empty() {
1553 println!("{} No workspaces with chat sessions found.", "[!]".yellow());
1554 return Ok(());
1555 }
1556
1557 println!(
1558 " Found {} workspaces with chat sessions\n",
1559 ws_with_sessions.len().to_string().cyan()
1560 );
1561
1562 let mut total_compacted = 0usize;
1563 let mut total_synced = 0usize;
1564 let mut succeeded = 0usize;
1565 let mut failed = 0usize;
1566
1567 for (i, ws) in ws_with_sessions.iter().enumerate() {
1568 let display_name = ws
1569 .project_path
1570 .as_deref()
1571 .unwrap_or(&ws.hash);
1572 println!(
1573 "[{}/{}] {} {}",
1574 i + 1,
1575 ws_with_sessions.len(),
1576 "===".dimmed(),
1577 display_name.cyan()
1578 );
1579
1580 let chat_sessions_dir = ws.workspace_path.join("chatSessions");
1581 if !chat_sessions_dir.exists() {
1582 println!(" {} No chatSessions directory, skipping.\n", "[!]".yellow());
1583 continue;
1584 }
1585
1586 match repair_workspace_sessions(&ws.hash, &chat_sessions_dir, true) {
1587 Ok((compacted, index_fixed)) => {
1588 let mut deleted_json = 0;
1590 let mut jsonl_sessions: HashSet<String> = HashSet::new();
1591 for entry in std::fs::read_dir(&chat_sessions_dir)? {
1592 let entry = entry?;
1593 let p = entry.path();
1594 if p.extension().is_some_and(|e| e == "jsonl") {
1595 if let Some(stem) = p.file_stem() {
1596 jsonl_sessions.insert(stem.to_string_lossy().to_string());
1597 }
1598 }
1599 }
1600 for entry in std::fs::read_dir(&chat_sessions_dir)? {
1601 let entry = entry?;
1602 let p = entry.path();
1603 if p.extension().is_some_and(|e| e == "json") {
1604 if let Some(stem) = p.file_stem() {
1605 if jsonl_sessions.contains(&stem.to_string_lossy().to_string()) {
1606 let bak = p.with_extension("json.bak");
1607 std::fs::rename(&p, &bak)?;
1608 deleted_json += 1;
1609 }
1610 }
1611 }
1612 }
1613 if deleted_json > 0 {
1614 repair_workspace_sessions(&ws.hash, &chat_sessions_dir, true)?;
1615 }
1616
1617 total_compacted += compacted;
1618 total_synced += index_fixed;
1619 succeeded += 1;
1620 println!(
1621 " {} {} compacted, {} synced{}\n",
1622 "[OK]".green(),
1623 compacted,
1624 index_fixed,
1625 if deleted_json > 0 {
1626 format!(", {} stale .json backed up", deleted_json)
1627 } else {
1628 String::new()
1629 }
1630 );
1631 }
1632 Err(e) => {
1633 failed += 1;
1634 println!(" {} {}\n", "[ERR]".red(), e);
1635 }
1636 }
1637 }
1638
1639 println!(
1640 "{} Repair complete: {}/{} workspaces, {} compacted, {} index entries synced",
1641 "[OK]".green().bold(),
1642 succeeded.to_string().green(),
1643 ws_with_sessions.len(),
1644 total_compacted.to_string().cyan(),
1645 total_synced.to_string().cyan()
1646 );
1647 if failed > 0 {
1648 println!(
1649 " {} {} workspace(s) had errors",
1650 "[!]".yellow(),
1651 failed.to_string().red()
1652 );
1653 }
1654
1655 if reopen && vscode_was_running {
1657 println!(" {} Reopening VS Code...", "[*]".yellow());
1658 reopen_vscode(None)?;
1659 println!(
1660 " {} VS Code launched. Sessions should now load correctly.",
1661 "[OK]".green()
1662 );
1663 } else if should_close && vscode_was_running {
1664 println!(
1665 "\n{} VS Code was closed. Reopen it to see the repaired sessions.",
1666 "[!]".yellow()
1667 );
1668 }
1669
1670 Ok(())
1671}