Skip to main content

chasm/commands/
detect.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: AGPL-3.0-only
3//! Auto-detection commands for workspaces and providers
4
5use anyhow::Result;
6use colored::*;
7use std::collections::HashMap;
8
9use crate::models::{Workspace, WorkspaceJson};
10use crate::providers::{ProviderRegistry, ProviderType};
11use crate::storage::{is_vscode_running, register_all_sessions_from_directory};
12use crate::workspace::{
13    decode_workspace_folder, discover_workspaces, find_workspace_by_path,
14    get_chat_sessions_from_workspace, get_workspace_storage_path, normalize_path,
15};
16
17/// Detect workspace information for a given path
18pub fn detect_workspace(path: Option<&str>) -> Result<()> {
19    let project_path = path.map(|p| p.to_string()).unwrap_or_else(|| {
20        std::env::current_dir()
21            .map(|p| p.to_string_lossy().to_string())
22            .unwrap_or_else(|_| ".".to_string())
23    });
24
25    println!("\n{} Detecting Workspace", "[D]".blue().bold());
26    println!("{}", "=".repeat(60));
27    println!("{} Path: {}", "[*]".blue(), project_path.cyan());
28
29    match find_workspace_by_path(&project_path)? {
30        Some((ws_id, ws_dir, ws_name)) => {
31            println!("\n{} Workspace Found!", "[+]".green().bold());
32            println!("   {} ID: {}", "[*]".blue(), &ws_id[..16.min(ws_id.len())]);
33            println!("   {} Directory: {}", "[*]".blue(), ws_dir.display());
34            if let Some(name) = ws_name {
35                println!("   {} Name: {}", "[*]".blue(), name.cyan());
36            }
37
38            // Get session count
39            if let Ok(sessions) = get_chat_sessions_from_workspace(&ws_dir) {
40                println!("   {} Sessions: {}", "[*]".blue(), sessions.len());
41
42                if !sessions.is_empty() {
43                    let total_messages: usize =
44                        sessions.iter().map(|s| s.session.request_count()).sum();
45                    println!("   {} Total Messages: {}", "[*]".blue(), total_messages);
46                }
47            }
48
49            // Detect provider
50            println!("\n{} Provider Detection:", "[*]".blue());
51            println!(
52                "   {} Provider: {}",
53                "[*]".blue(),
54                "GitHub Copilot (VS Code)".cyan()
55            );
56        }
57        None => {
58            println!("\n{} No workspace found for this path", "[X]".red());
59            println!(
60                "{} The project may not have been opened in VS Code yet",
61                "[i]".yellow()
62            );
63
64            // Check if there are similar workspaces
65            let all_workspaces = discover_workspaces()?;
66            let path_lower = project_path.to_lowercase();
67            let similar: Vec<&Workspace> = all_workspaces
68                .iter()
69                .filter(|ws| {
70                    ws.project_path
71                        .as_ref()
72                        .map(|p| {
73                            p.to_lowercase().contains(&path_lower)
74                                || path_lower.contains(&p.to_lowercase())
75                        })
76                        .unwrap_or(false)
77                })
78                .take(5)
79                .collect();
80
81            if !similar.is_empty() {
82                println!("\n{} Similar workspaces found:", "[i]".cyan());
83                for ws in similar {
84                    if let Some(p) = &ws.project_path {
85                        println!("   {} {}...", p.cyan(), &ws.hash[..8.min(ws.hash.len())]);
86                    }
87                }
88            }
89        }
90    }
91
92    Ok(())
93}
94
95/// Detect available providers and their status
96pub fn detect_providers(with_sessions: bool) -> Result<()> {
97    println!("\n{} Detecting Providers", "[D]".blue().bold());
98    println!("{}", "=".repeat(60));
99
100    let registry = ProviderRegistry::new();
101    let mut found_count = 0;
102    let mut with_sessions_count = 0;
103
104    let all_provider_types = vec![
105        ProviderType::Copilot,
106        ProviderType::Cursor,
107        ProviderType::Ollama,
108        ProviderType::Vllm,
109        ProviderType::Foundry,
110        ProviderType::LmStudio,
111        ProviderType::LocalAI,
112        ProviderType::TextGenWebUI,
113        ProviderType::Jan,
114        ProviderType::Gpt4All,
115        ProviderType::Llamafile,
116    ];
117
118    for provider_type in all_provider_types {
119        if let Some(provider) = registry.get_provider(provider_type) {
120            let available = provider.is_available();
121            let session_count = if available {
122                provider.list_sessions().map(|s| s.len()).unwrap_or(0)
123            } else {
124                0
125            };
126
127            if with_sessions && session_count == 0 {
128                continue;
129            }
130
131            found_count += 1;
132            if session_count > 0 {
133                with_sessions_count += 1;
134            }
135
136            let status = if available {
137                if session_count > 0 {
138                    format!(
139                        "{} ({} sessions)",
140                        "+".green(),
141                        session_count.to_string().cyan()
142                    )
143                } else {
144                    format!("{} (no sessions)", "+".green())
145                }
146            } else {
147                format!("{} not available", "x".red())
148            };
149
150            println!("   {} {}: {}", "[*]".blue(), provider.name().bold(), status);
151
152            // Show endpoint for API-based providers
153            if available {
154                if let Some(endpoint) = provider_type.default_endpoint() {
155                    println!("      {} Endpoint: {}", "`".dimmed(), endpoint.dimmed());
156                }
157                if let Some(path) = provider.sessions_path() {
158                    println!(
159                        "      {} Path: {}",
160                        "`".dimmed(),
161                        path.display().to_string().dimmed()
162                    );
163                }
164            }
165        }
166    }
167
168    println!("\n{} Summary:", "[*]".green().bold());
169    println!("   {} providers available", found_count.to_string().cyan());
170    println!(
171        "   {} providers with sessions",
172        with_sessions_count.to_string().cyan()
173    );
174
175    Ok(())
176}
177
178/// Detect which provider a session belongs to
179pub fn detect_session(session_id: &str, path: Option<&str>) -> Result<()> {
180    println!("\n{} Detecting Session Provider", "[D]".blue().bold());
181    println!("{}", "=".repeat(60));
182    println!("{} Session: {}", "[*]".blue(), session_id.cyan());
183
184    let registry = ProviderRegistry::new();
185    let session_lower = session_id.to_lowercase();
186    let mut found = false;
187
188    // First check VS Code workspaces
189    let project_path = path.map(|p| p.to_string()).unwrap_or_else(|| {
190        std::env::current_dir()
191            .map(|p| p.to_string_lossy().to_string())
192            .unwrap_or_else(|_| ".".to_string())
193    });
194
195    // Check in VS Code/Copilot workspaces
196    if let Ok(Some((_ws_id, ws_dir, ws_name))) = find_workspace_by_path(&project_path) {
197        if let Ok(sessions) = get_chat_sessions_from_workspace(&ws_dir) {
198            for swp in &sessions {
199                let sid = swp
200                    .session
201                    .session_id
202                    .as_ref()
203                    .map(|s| s.to_lowercase())
204                    .unwrap_or_default();
205                let title = swp.session.title().to_lowercase();
206                let filename = swp
207                    .path
208                    .file_name()
209                    .map(|f| f.to_string_lossy().to_lowercase())
210                    .unwrap_or_default();
211
212                if sid.contains(&session_lower)
213                    || title.contains(&session_lower)
214                    || filename.contains(&session_lower)
215                {
216                    found = true;
217                    println!("\n{} Session Found!", "[+]".green().bold());
218                    println!("   {} Provider: {}", "[*]".blue(), "GitHub Copilot".cyan());
219                    println!("   {} Title: {}", "[*]".blue(), swp.session.title());
220                    println!("   {} File: {}", "[*]".blue(), swp.path.display());
221                    println!(
222                        "   {} Messages: {}",
223                        "[*]".blue(),
224                        swp.session.request_count()
225                    );
226                    if let Some(name) = &ws_name {
227                        println!("   {} Workspace: {}", "[*]".blue(), name);
228                    }
229                    break;
230                }
231            }
232        }
233    }
234
235    // Check other providers
236    if !found {
237        let provider_types = vec![
238            ProviderType::Cursor,
239            ProviderType::Ollama,
240            ProviderType::Jan,
241            ProviderType::Gpt4All,
242            ProviderType::LmStudio,
243        ];
244
245        for provider_type in provider_types {
246            if let Some(provider) = registry.get_provider(provider_type) {
247                if provider.is_available() {
248                    if let Ok(sessions) = provider.list_sessions() {
249                        for session in sessions {
250                            let sid = session
251                                .session_id
252                                .as_ref()
253                                .map(|s| s.to_lowercase())
254                                .unwrap_or_default();
255                            let title = session.title().to_lowercase();
256
257                            if sid.contains(&session_lower) || title.contains(&session_lower) {
258                                found = true;
259                                println!("\n{} Session Found!", "[+]".green().bold());
260                                println!(
261                                    "   {} Provider: {}",
262                                    "[*]".blue(),
263                                    provider.name().cyan()
264                                );
265                                println!("   {} Title: {}", "[*]".blue(), session.title());
266                                println!(
267                                    "   {} Messages: {}",
268                                    "[*]".blue(),
269                                    session.request_count()
270                                );
271                                break;
272                            }
273                        }
274                    }
275                }
276            }
277            if found {
278                break;
279            }
280        }
281    }
282
283    if !found {
284        println!("\n{} Session not found", "[X]".red());
285        println!(
286            "{} Try providing a more specific session ID or check the path",
287            "[i]".yellow()
288        );
289    }
290
291    Ok(())
292}
293
294/// Detect everything (workspace, providers, sessions) for a path
295pub fn detect_all(path: Option<&str>, verbose: bool) -> Result<()> {
296    let project_path = path.map(|p| p.to_string()).unwrap_or_else(|| {
297        std::env::current_dir()
298            .map(|p| p.to_string_lossy().to_string())
299            .unwrap_or_else(|_| ".".to_string())
300    });
301
302    println!("\n{} Auto-Detection Report", "[D]".blue().bold());
303    println!("{}", "=".repeat(70));
304    println!("{} Path: {}", "[*]".blue(), project_path.cyan());
305    println!();
306
307    // 1. Workspace Detection
308    println!("{} Workspace", "---".dimmed());
309    let workspace_info = find_workspace_by_path(&project_path)?;
310
311    match &workspace_info {
312        Some((ws_id, ws_dir, ws_name)) => {
313            println!("   {} Status: {}", "[+]".green(), "Found".green());
314            println!(
315                "   {} ID: {}...",
316                "[*]".blue(),
317                &ws_id[..16.min(ws_id.len())]
318            );
319            if let Some(name) = ws_name {
320                println!("   {} Name: {}", "[*]".blue(), name.cyan());
321            }
322
323            // Get sessions from workspace
324            if let Ok(sessions) = get_chat_sessions_from_workspace(ws_dir) {
325                println!("   {} Sessions: {}", "[*]".blue(), sessions.len());
326
327                if verbose && !sessions.is_empty() {
328                    println!("\n   {} Recent Sessions:", "[*]".blue());
329                    for (i, swp) in sessions.iter().take(5).enumerate() {
330                        println!(
331                            "      {}. {} ({} messages)",
332                            i + 1,
333                            truncate(&swp.session.title(), 40),
334                            swp.session.request_count()
335                        );
336                    }
337                    if sessions.len() > 5 {
338                        println!("      ... and {} more", sessions.len() - 5);
339                    }
340                }
341            }
342        }
343        None => {
344            println!("   {} Status: {}", "[X]".red(), "Not found".red());
345            println!(
346                "   {} Open this project in VS Code to create a workspace",
347                "[i]".yellow()
348            );
349        }
350    }
351    println!();
352
353    // 2. Provider Detection
354    println!("{} Available Providers", "---".dimmed());
355
356    let registry = ProviderRegistry::new();
357    let provider_types = vec![
358        ProviderType::Copilot,
359        ProviderType::Cursor,
360        ProviderType::Ollama,
361        ProviderType::Vllm,
362        ProviderType::Foundry,
363        ProviderType::LmStudio,
364        ProviderType::LocalAI,
365        ProviderType::TextGenWebUI,
366        ProviderType::Jan,
367        ProviderType::Gpt4All,
368        ProviderType::Llamafile,
369    ];
370
371    let mut total_sessions = 0;
372    let mut provider_summary: Vec<(String, usize)> = Vec::new();
373
374    for provider_type in provider_types {
375        if let Some(provider) = registry.get_provider(provider_type) {
376            if provider.is_available() {
377                let session_count = provider.list_sessions().map(|s| s.len()).unwrap_or(0);
378
379                if session_count > 0 || verbose {
380                    let status = if session_count > 0 {
381                        format!("{} sessions", session_count.to_string().cyan())
382                    } else {
383                        "ready".dimmed().to_string()
384                    };
385                    println!("   {} {}: {}", "[+]".green(), provider.name(), status);
386
387                    total_sessions += session_count;
388                    if session_count > 0 {
389                        provider_summary.push((provider.name().to_string(), session_count));
390                    }
391                }
392            }
393        }
394    }
395
396    if provider_summary.is_empty() && !verbose {
397        println!("   {} No providers with sessions found", "[i]".yellow());
398        println!(
399            "   {} Use --verbose to see all available providers",
400            "[i]".dimmed()
401        );
402    }
403    println!();
404
405    // 3. Summary
406    println!("{} Summary", "---".dimmed());
407
408    let ws_status = if workspace_info.is_some() {
409        "Yes".green()
410    } else {
411        "No".red()
412    };
413    println!("   {} Workspace detected: {}", "[*]".blue(), ws_status);
414    println!(
415        "   {} Total providers with sessions: {}",
416        "[*]".blue(),
417        provider_summary.len()
418    );
419    println!(
420        "   {} Total sessions across providers: {}",
421        "[*]".blue(),
422        total_sessions
423    );
424
425    // 4. Recommendations
426    if workspace_info.is_none() || total_sessions == 0 {
427        println!();
428        println!("{} Recommendations", "---".dimmed());
429
430        if workspace_info.is_none() {
431            println!(
432                "   {} Open this project in VS Code to enable chat history tracking",
433                "[->]".cyan()
434            );
435        }
436
437        if total_sessions == 0 {
438            println!(
439                "   {} Start a chat session in your IDE to create history",
440                "[->]".cyan()
441            );
442        }
443    }
444
445    Ok(())
446}
447
448/// Helper function to truncate strings
449fn truncate(s: &str, max_len: usize) -> String {
450    if s.len() <= max_len {
451        s.to_string()
452    } else {
453        format!("{}...", &s[..max_len - 3])
454    }
455}
456
457/// Detect all workspace hashes for a project path (including orphaned workspaces)
458/// This helps find sessions that exist on disk but are in old/orphaned workspace folders
459pub fn detect_orphaned(path: Option<&str>, recover: bool) -> Result<()> {
460    use crate::models::WorkspaceJson;
461    use crate::workspace::{decode_workspace_folder, get_workspace_storage_path, normalize_path};
462
463    let project_path = path.map(|p| p.to_string()).unwrap_or_else(|| {
464        std::env::current_dir()
465            .map(|p| p.to_string_lossy().to_string())
466            .unwrap_or_else(|_| ".".to_string())
467    });
468
469    println!("\n{} Scanning for Orphaned Sessions", "[D]".blue().bold());
470    println!("{}", "=".repeat(60));
471    println!("{} Path: {}", "[*]".blue(), project_path.cyan());
472
473    let storage_path = get_workspace_storage_path()?;
474    let target_path = normalize_path(&project_path);
475
476    // Find ALL workspace hashes that match this path
477    let mut all_workspaces: Vec<(String, std::path::PathBuf, usize, std::time::SystemTime)> =
478        Vec::new();
479
480    for entry in std::fs::read_dir(&storage_path)? {
481        let entry = entry?;
482        let workspace_dir = entry.path();
483
484        if !workspace_dir.is_dir() {
485            continue;
486        }
487
488        let workspace_json_path = workspace_dir.join("workspace.json");
489        if !workspace_json_path.exists() {
490            continue;
491        }
492
493        if let Ok(content) = std::fs::read_to_string(&workspace_json_path) {
494            if let Ok(ws_json) = serde_json::from_str::<WorkspaceJson>(&content) {
495                if let Some(folder) = &ws_json.folder {
496                    let folder_path = decode_workspace_folder(folder);
497                    if normalize_path(&folder_path) == target_path {
498                        // Count sessions in this workspace
499                        let chat_sessions_dir = workspace_dir.join("chatSessions");
500                        let session_count = if chat_sessions_dir.exists() {
501                            std::fs::read_dir(&chat_sessions_dir)
502                                .map(|entries| {
503                                    entries
504                                        .filter_map(|e| e.ok())
505                                        .filter(|e| {
506                                            e.path()
507                                                .extension()
508                                                .map(|ext| {
509                                                    ext == "json"
510                                                        || ext == "jsonl"
511                                                        || ext == "backup"
512                                                })
513                                                .unwrap_or(false)
514                                        })
515                                        .count()
516                                })
517                                .unwrap_or(0)
518                        } else {
519                            0
520                        };
521
522                        // Get last modified time
523                        let last_modified = if chat_sessions_dir.exists() {
524                            std::fs::read_dir(&chat_sessions_dir)
525                                .ok()
526                                .and_then(|entries| {
527                                    entries
528                                        .filter_map(|e| e.ok())
529                                        .filter_map(|e| e.metadata().ok())
530                                        .filter_map(|m| m.modified().ok())
531                                        .max()
532                                })
533                                .unwrap_or(std::time::UNIX_EPOCH)
534                        } else {
535                            std::time::UNIX_EPOCH
536                        };
537
538                        all_workspaces.push((
539                            entry.file_name().to_string_lossy().to_string(),
540                            workspace_dir,
541                            session_count,
542                            last_modified,
543                        ));
544                    }
545                }
546            }
547        }
548    }
549
550    if all_workspaces.is_empty() {
551        println!("\n{} No workspaces found for this path", "[X]".red());
552        return Ok(());
553    }
554
555    // Sort by last modified (newest first)
556    all_workspaces.sort_by(|a, b| b.3.cmp(&a.3));
557
558    // The first one (most recently modified) is the "active" workspace
559    let active_dir = all_workspaces[0].1.clone();
560
561    println!(
562        "\n{} Found {} workspace(s) for this path:",
563        "[+]".green().bold(),
564        all_workspaces.len()
565    );
566
567    let mut total_orphaned_sessions = 0;
568    let mut orphaned_workspaces: Vec<(String, std::path::PathBuf, usize)> = Vec::new();
569
570    for (i, (hash, dir, session_count, _)) in all_workspaces.iter().enumerate() {
571        let is_active = i == 0;
572        let status = if is_active {
573            format!("{}", "(active)".green())
574        } else {
575            format!("{}", "(orphaned)".yellow())
576        };
577
578        let session_str = if *session_count > 0 {
579            format!("{} sessions", session_count.to_string().cyan())
580        } else {
581            "0 sessions".dimmed().to_string()
582        };
583
584        println!(
585            "   {} {}... {} - {}",
586            if is_active {
587                "[*]".green()
588            } else {
589                "[!]".yellow()
590            },
591            &hash[..16.min(hash.len())],
592            status,
593            session_str
594        );
595
596        if !is_active && *session_count > 0 {
597            total_orphaned_sessions += session_count;
598            orphaned_workspaces.push((hash.clone(), dir.clone(), *session_count));
599
600            // Show session details
601            let chat_sessions_dir = dir.join("chatSessions");
602            if let Ok(entries) = std::fs::read_dir(&chat_sessions_dir) {
603                for entry in entries.filter_map(|e| e.ok()).take(3) {
604                    let path = entry.path();
605                    if path.extension().map(|e| e == "json").unwrap_or(false) {
606                        if let Ok(content) = std::fs::read_to_string(&path) {
607                            if let Ok(session) = crate::storage::parse_session_json(&content) {
608                                let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
609                                let size_str = if size > 1_000_000 {
610                                    format!("{:.1}MB", size as f64 / 1_000_000.0)
611                                } else if size > 1000 {
612                                    format!("{:.1}KB", size as f64 / 1000.0)
613                                } else {
614                                    format!("{}B", size)
615                                };
616                                println!(
617                                    "      {} {} ({}, {} msgs)",
618                                    "`".dimmed(),
619                                    truncate(&session.title(), 45),
620                                    size_str.cyan(),
621                                    session.request_count()
622                                );
623                            }
624                        }
625                    }
626                }
627            }
628        }
629    }
630
631    // Summary
632    println!();
633    if total_orphaned_sessions > 0 {
634        println!(
635            "{} {} orphaned session(s) found in {} workspace(s)",
636            "[!]".yellow().bold(),
637            total_orphaned_sessions.to_string().yellow(),
638            orphaned_workspaces.len()
639        );
640
641        if recover {
642            // Recover orphaned sessions
643            println!("\n{} Recovering orphaned sessions...", "[*]".blue());
644
645            let active_chat_sessions = active_dir.join("chatSessions");
646            if !active_chat_sessions.exists() {
647                std::fs::create_dir_all(&active_chat_sessions)?;
648            }
649
650            let mut recovered = 0;
651            for (hash, orphan_dir, _) in &orphaned_workspaces {
652                let orphan_sessions = orphan_dir.join("chatSessions");
653                if let Ok(entries) = std::fs::read_dir(&orphan_sessions) {
654                    for entry in entries.filter_map(|e| e.ok()) {
655                        let src = entry.path();
656                        let ext_match = src
657                            .extension()
658                            .map(|e| e == "json" || e == "jsonl" || e == "backup")
659                            .unwrap_or(false);
660                        let is_bak = src.to_string_lossy().ends_with(".bak")
661                            || src.to_string_lossy().ends_with(".corrupt");
662                        if ext_match && !is_bak {
663                            let filename = src.file_name().unwrap();
664                            let dest = active_chat_sessions.join(filename);
665                            if !dest.exists() {
666                                std::fs::copy(&src, &dest)?;
667                                recovered += 1;
668                                println!(
669                                    "   {} Copied: {} (from {}...)",
670                                    "[+]".green(),
671                                    filename.to_string_lossy(),
672                                    &hash[..8]
673                                );
674                            }
675                        }
676                    }
677                }
678            }
679
680            println!(
681                "\n{} Recovered {} session(s)",
682                "[OK]".green().bold(),
683                recovered
684            );
685            println!(
686                "\n{} Run {} to make them visible in VS Code",
687                "[i]".cyan(),
688                "chasm register all --force".cyan()
689            );
690        } else {
691            println!(
692                "\n{} To recover, run: {}",
693                "[->]".cyan(),
694                format!(
695                    "chasm detect orphaned --recover --path \"{}\"",
696                    project_path
697                )
698                .cyan()
699            );
700        }
701    } else {
702        println!("{} No orphaned sessions found", "[OK]".green().bold());
703    }
704
705    Ok(())
706}
707
708/// Recursively walk a directory tree and recover orphaned sessions for all workspaces found.
709///
710/// This combines the logic of `detect_orphaned` (finding orphaned workspace hashes and
711/// copying sessions to the active workspace) with `register_recursive` (using the workspace-map
712/// filter for fast scanning). Optionally registers recovered sessions in VS Code's index.
713pub fn recover_recursive(
714    root_path: Option<&str>,
715    max_depth: Option<usize>,
716    force: bool,
717    dry_run: bool,
718    exclude_patterns: &[String],
719    register: bool,
720) -> Result<()> {
721    let root = super::register::resolve_path(root_path);
722    let root_normalized = normalize_path(&root.to_string_lossy());
723
724    println!(
725        "\n{} Recovering orphaned sessions under: {}",
726        "[R]".green().bold(),
727        root.display()
728    );
729    println!("{}", "=".repeat(60));
730
731    if dry_run {
732        println!("{} Dry run mode — no changes will be made", "[!]".yellow());
733    }
734
735    // Check if VS Code is running (warn for register, but recovery is safe either way)
736    if register && !force && !dry_run && is_vscode_running() {
737        println!(
738            "{} VS Code is running. Use {} to register anyway.",
739            "[!]".yellow(),
740            "--force".cyan()
741        );
742    }
743
744    let storage_path = get_workspace_storage_path()?;
745
746    // Build a map of project_path → Vec<(hash, workspace_dir, session_count, last_modified)>
747    // from ALL workspace storage directories. This finds orphaned hashes that
748    // `discover_workspaces` may not surface with full detail.
749    let mut path_workspaces: HashMap<
750        String,
751        Vec<(String, std::path::PathBuf, usize, std::time::SystemTime)>,
752    > = HashMap::new();
753
754    for entry in std::fs::read_dir(&storage_path)? {
755        let entry = entry?;
756        let workspace_dir = entry.path();
757        if !workspace_dir.is_dir() {
758            continue;
759        }
760
761        let workspace_json_path = workspace_dir.join("workspace.json");
762        if !workspace_json_path.exists() {
763            continue;
764        }
765
766        let content = match std::fs::read_to_string(&workspace_json_path) {
767            Ok(c) => c,
768            Err(_) => continue,
769        };
770        let ws_json: WorkspaceJson = match serde_json::from_str(&content) {
771            Ok(j) => j,
772            Err(_) => continue,
773        };
774        let folder = match &ws_json.folder {
775            Some(f) => f.clone(),
776            None => continue,
777        };
778
779        let folder_path = decode_workspace_folder(&folder);
780        let normalized = normalize_path(&folder_path);
781
782        // Only include workspaces under the root
783        if !normalized.starts_with(&root_normalized) {
784            continue;
785        }
786
787        // Apply depth limit
788        if let Some(max) = max_depth {
789            let suffix = &normalized[root_normalized.len()..];
790            let suffix = suffix.trim_start_matches(['/', '\\']);
791            let depth = if suffix.is_empty() {
792                0
793            } else {
794                suffix.matches(['/', '\\']).count() + 1
795            };
796            if depth > max {
797                continue;
798            }
799        }
800
801        // Apply exclude patterns
802        let relative = &normalized[root_normalized.len()..];
803        let relative = relative.trim_start_matches(['/', '\\']);
804        let default_excludes = [
805            "node_modules",
806            ".git",
807            "target",
808            "build",
809            "dist",
810            ".venv",
811            "venv",
812            "__pycache__",
813            ".cache",
814            "vendor",
815            ".cargo",
816        ];
817        let skip = relative
818            .split(['/', '\\'])
819            .any(|c| default_excludes.contains(&c));
820        if skip {
821            continue;
822        }
823
824        let exclude_matchers: Vec<glob::Pattern> = exclude_patterns
825            .iter()
826            .filter_map(|p| glob::Pattern::new(p).ok())
827            .collect();
828        let dir_name = folder_path
829            .rsplit(['/', '\\'])
830            .next()
831            .unwrap_or("")
832            .to_lowercase();
833        let excluded_by_user = exclude_matchers
834            .iter()
835            .any(|p| p.matches(relative) || p.matches(&dir_name));
836        if excluded_by_user {
837            continue;
838        }
839
840        // Count sessions
841        let chat_sessions_dir = workspace_dir.join("chatSessions");
842        let session_count = if chat_sessions_dir.exists() {
843            std::fs::read_dir(&chat_sessions_dir)
844                .map(|entries| {
845                    entries
846                        .filter_map(|e| e.ok())
847                        .filter(|e| {
848                            e.path()
849                                .extension()
850                                .map(|ext| ext == "json" || ext == "jsonl" || ext == "backup")
851                                .unwrap_or(false)
852                        })
853                        .count()
854                })
855                .unwrap_or(0)
856        } else {
857            0
858        };
859
860        let last_modified = if chat_sessions_dir.exists() {
861            std::fs::read_dir(&chat_sessions_dir)
862                .ok()
863                .and_then(|entries| {
864                    entries
865                        .filter_map(|e| e.ok())
866                        .filter_map(|e| e.metadata().ok())
867                        .filter_map(|m| m.modified().ok())
868                        .max()
869                })
870                .unwrap_or(std::time::UNIX_EPOCH)
871        } else {
872            std::time::UNIX_EPOCH
873        };
874
875        let hash = entry.file_name().to_string_lossy().to_string();
876        path_workspaces
877            .entry(normalized.clone())
878            .or_default()
879            .push((hash, workspace_dir, session_count, last_modified));
880    }
881
882    // Sort each project's workspaces by last modified (newest first = active)
883    for workspaces in path_workspaces.values_mut() {
884        workspaces.sort_by(|a, b| b.3.cmp(&a.3));
885    }
886
887    // Find projects with orphaned workspaces (more than one hash, or a hash with sessions
888    // that's not the newest)
889    let total_projects = path_workspaces.len();
890    let mut projects_with_orphans = 0;
891    let mut total_sessions_recovered = 0;
892    let mut total_sessions_registered = 0;
893    let mut processed = 0;
894
895    // Sort projects for deterministic output
896    let mut project_paths: Vec<String> = path_workspaces.keys().cloned().collect();
897    project_paths.sort();
898
899    println!(
900        "   Found {} unique project paths under this root",
901        total_projects.to_string().cyan()
902    );
903
904    for project_normalized in &project_paths {
905        let workspaces = &path_workspaces[project_normalized];
906        processed += 1;
907
908        if (processed) % 50 == 0 || processed == total_projects {
909            println!(
910                "   ... scanning {}/{}",
911                processed.to_string().cyan(),
912                total_projects.to_string().white()
913            );
914        }
915
916        if workspaces.len() <= 1 {
917            // Only one workspace hash — no orphan recovery needed
918            continue;
919        }
920
921        // Active = first (most recent), rest = orphaned
922        let (ref active_hash, ref active_dir, active_count, _) = workspaces[0];
923        let orphaned = &workspaces[1..];
924
925        // Count total orphaned sessions
926        let orphaned_with_sessions: Vec<&(
927            String,
928            std::path::PathBuf,
929            usize,
930            std::time::SystemTime,
931        )> = orphaned
932            .iter()
933            .filter(|(_, _, count, _)| *count > 0)
934            .collect();
935
936        if orphaned_with_sessions.is_empty() {
937            continue;
938        }
939
940        let orphan_session_count: usize = orphaned_with_sessions.iter().map(|(_, _, c, _)| c).sum();
941        projects_with_orphans += 1;
942
943        // Display path relative to root for readability
944        let display_path = if project_normalized.len() > root_normalized.len() {
945            project_normalized[root_normalized.len()..].trim_start_matches(['/', '\\'])
946        } else {
947            project_normalized.as_str()
948        };
949
950        if dry_run {
951            println!(
952                "   {} {} — {} workspace(s), active has {} sessions, {} orphaned session(s) in {} hash(es)",
953                "[DRY]".yellow(),
954                display_path.cyan(),
955                workspaces.len().to_string().white(),
956                active_count.to_string().white(),
957                orphan_session_count.to_string().yellow(),
958                orphaned_with_sessions.len().to_string().yellow(),
959            );
960            total_sessions_recovered += orphan_session_count;
961            continue;
962        }
963
964        // Recover: copy orphaned sessions to active workspace's chatSessions dir
965        let active_chat_sessions = active_dir.join("chatSessions");
966        if !active_chat_sessions.exists() {
967            std::fs::create_dir_all(&active_chat_sessions)?;
968        }
969
970        let mut recovered_this_project = 0;
971        for (_orphan_hash, orphan_dir, _, _) in &orphaned_with_sessions {
972            let orphan_sessions = orphan_dir.join("chatSessions");
973            if let Ok(entries) = std::fs::read_dir(&orphan_sessions) {
974                for entry in entries.filter_map(|e| e.ok()) {
975                    let src = entry.path();
976                    let ext_match = src
977                        .extension()
978                        .map(|e| e == "json" || e == "jsonl" || e == "backup")
979                        .unwrap_or(false);
980                    let is_bak = src.to_string_lossy().ends_with(".bak")
981                        || src.to_string_lossy().ends_with(".corrupt");
982                    if ext_match && !is_bak {
983                        let filename = src.file_name().unwrap();
984                        let dest = active_chat_sessions.join(filename);
985                        if !dest.exists() {
986                            std::fs::copy(&src, &dest)?;
987                            recovered_this_project += 1;
988                        }
989                    }
990                }
991            }
992        }
993
994        if recovered_this_project > 0 {
995            println!(
996                "   {} {} — recovered {} session(s) from {} orphaned hash(es)",
997                "[+]".green(),
998                display_path.cyan(),
999                recovered_this_project.to_string().green(),
1000                orphaned_with_sessions.len().to_string().white(),
1001            );
1002            total_sessions_recovered += recovered_this_project;
1003
1004            // Optionally register
1005            if register {
1006                match register_all_sessions_from_directory(
1007                    active_hash,
1008                    &active_chat_sessions,
1009                    force,
1010                ) {
1011                    Ok(registered) => {
1012                        total_sessions_registered += registered;
1013                    }
1014                    Err(e) => {
1015                        println!(
1016                            "   {} Failed to register for {}: {}",
1017                            "[!]".red(),
1018                            display_path,
1019                            e
1020                        );
1021                    }
1022                }
1023            }
1024        }
1025    }
1026
1027    // Summary
1028    println!("\n{}", "═".repeat(60).cyan());
1029    println!("{} Recursive recovery complete", "[OK]".green().bold());
1030    println!("{}", "═".repeat(60).cyan());
1031    println!(
1032        "   Projects scanned:       {}",
1033        total_projects.to_string().cyan()
1034    );
1035    println!(
1036        "   Projects with orphans:  {}",
1037        projects_with_orphans.to_string().yellow()
1038    );
1039    println!(
1040        "   Sessions recovered:     {}",
1041        total_sessions_recovered.to_string().green()
1042    );
1043    if register {
1044        println!(
1045            "   Sessions registered:    {}",
1046            total_sessions_registered.to_string().green()
1047        );
1048    }
1049
1050    if !dry_run && total_sessions_recovered > 0 {
1051        if !register {
1052            println!(
1053                "\n{} Run {} to make them visible in VS Code",
1054                "[i]".cyan(),
1055                format!("chasm register recursive --force \"{}\"", root.display()).cyan()
1056            );
1057        } else {
1058            println!(
1059                "\n{} Reload VS Code (Developer: Reload Window) to see recovered sessions",
1060                "[i]".cyan()
1061            );
1062        }
1063    }
1064
1065    Ok(())
1066}