Skip to main content

chasm_cli/commands/
detect.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: Apache-2.0
3//! Auto-detection commands for workspaces and providers
4
5use anyhow::Result;
6use colored::*;
7
8use crate::models::Workspace;
9use crate::providers::{ProviderRegistry, ProviderType};
10use crate::workspace::{
11    discover_workspaces, find_workspace_by_path, get_chat_sessions_from_workspace,
12};
13
14/// Detect workspace information for a given path
15pub fn detect_workspace(path: Option<&str>) -> Result<()> {
16    let project_path = path.map(|p| p.to_string()).unwrap_or_else(|| {
17        std::env::current_dir()
18            .map(|p| p.to_string_lossy().to_string())
19            .unwrap_or_else(|_| ".".to_string())
20    });
21
22    println!("\n{} Detecting Workspace", "[D]".blue().bold());
23    println!("{}", "=".repeat(60));
24    println!("{} Path: {}", "[*]".blue(), project_path.cyan());
25
26    match find_workspace_by_path(&project_path)? {
27        Some((ws_id, ws_dir, ws_name)) => {
28            println!("\n{} Workspace Found!", "[+]".green().bold());
29            println!("   {} ID: {}", "[*]".blue(), &ws_id[..16.min(ws_id.len())]);
30            println!("   {} Directory: {}", "[*]".blue(), ws_dir.display());
31            if let Some(name) = ws_name {
32                println!("   {} Name: {}", "[*]".blue(), name.cyan());
33            }
34
35            // Get session count
36            if let Ok(sessions) = get_chat_sessions_from_workspace(&ws_dir) {
37                println!("   {} Sessions: {}", "[*]".blue(), sessions.len());
38
39                if !sessions.is_empty() {
40                    let total_messages: usize =
41                        sessions.iter().map(|s| s.session.request_count()).sum();
42                    println!("   {} Total Messages: {}", "[*]".blue(), total_messages);
43                }
44            }
45
46            // Detect provider
47            println!("\n{} Provider Detection:", "[*]".blue());
48            println!(
49                "   {} Provider: {}",
50                "[*]".blue(),
51                "GitHub Copilot (VS Code)".cyan()
52            );
53        }
54        None => {
55            println!("\n{} No workspace found for this path", "[X]".red());
56            println!(
57                "{} The project may not have been opened in VS Code yet",
58                "[i]".yellow()
59            );
60
61            // Check if there are similar workspaces
62            let all_workspaces = discover_workspaces()?;
63            let path_lower = project_path.to_lowercase();
64            let similar: Vec<&Workspace> = all_workspaces
65                .iter()
66                .filter(|ws| {
67                    ws.project_path
68                        .as_ref()
69                        .map(|p| {
70                            p.to_lowercase().contains(&path_lower)
71                                || path_lower.contains(&p.to_lowercase())
72                        })
73                        .unwrap_or(false)
74                })
75                .take(5)
76                .collect();
77
78            if !similar.is_empty() {
79                println!("\n{} Similar workspaces found:", "[i]".cyan());
80                for ws in similar {
81                    if let Some(p) = &ws.project_path {
82                        println!("   {} {}...", p.cyan(), &ws.hash[..8.min(ws.hash.len())]);
83                    }
84                }
85            }
86        }
87    }
88
89    Ok(())
90}
91
92/// Detect available providers and their status
93pub fn detect_providers(with_sessions: bool) -> Result<()> {
94    println!("\n{} Detecting Providers", "[D]".blue().bold());
95    println!("{}", "=".repeat(60));
96
97    let registry = ProviderRegistry::new();
98    let mut found_count = 0;
99    let mut with_sessions_count = 0;
100
101    let all_provider_types = vec![
102        ProviderType::Copilot,
103        ProviderType::Cursor,
104        ProviderType::Ollama,
105        ProviderType::Vllm,
106        ProviderType::Foundry,
107        ProviderType::LmStudio,
108        ProviderType::LocalAI,
109        ProviderType::TextGenWebUI,
110        ProviderType::Jan,
111        ProviderType::Gpt4All,
112        ProviderType::Llamafile,
113    ];
114
115    for provider_type in all_provider_types {
116        if let Some(provider) = registry.get_provider(provider_type) {
117            let available = provider.is_available();
118            let session_count = if available {
119                provider.list_sessions().map(|s| s.len()).unwrap_or(0)
120            } else {
121                0
122            };
123
124            if with_sessions && session_count == 0 {
125                continue;
126            }
127
128            found_count += 1;
129            if session_count > 0 {
130                with_sessions_count += 1;
131            }
132
133            let status = if available {
134                if session_count > 0 {
135                    format!(
136                        "{} ({} sessions)",
137                        "+".green(),
138                        session_count.to_string().cyan()
139                    )
140                } else {
141                    format!("{} (no sessions)", "+".green())
142                }
143            } else {
144                format!("{} not available", "x".red())
145            };
146
147            println!("   {} {}: {}", "[*]".blue(), provider.name().bold(), status);
148
149            // Show endpoint for API-based providers
150            if available {
151                if let Some(endpoint) = provider_type.default_endpoint() {
152                    println!("      {} Endpoint: {}", "`".dimmed(), endpoint.dimmed());
153                }
154                if let Some(path) = provider.sessions_path() {
155                    println!(
156                        "      {} Path: {}",
157                        "`".dimmed(),
158                        path.display().to_string().dimmed()
159                    );
160                }
161            }
162        }
163    }
164
165    println!("\n{} Summary:", "[*]".green().bold());
166    println!("   {} providers available", found_count.to_string().cyan());
167    println!(
168        "   {} providers with sessions",
169        with_sessions_count.to_string().cyan()
170    );
171
172    Ok(())
173}
174
175/// Detect which provider a session belongs to
176pub fn detect_session(session_id: &str, path: Option<&str>) -> Result<()> {
177    println!("\n{} Detecting Session Provider", "[D]".blue().bold());
178    println!("{}", "=".repeat(60));
179    println!("{} Session: {}", "[*]".blue(), session_id.cyan());
180
181    let registry = ProviderRegistry::new();
182    let session_lower = session_id.to_lowercase();
183    let mut found = false;
184
185    // First check VS Code workspaces
186    let project_path = path.map(|p| p.to_string()).unwrap_or_else(|| {
187        std::env::current_dir()
188            .map(|p| p.to_string_lossy().to_string())
189            .unwrap_or_else(|_| ".".to_string())
190    });
191
192    // Check in VS Code/Copilot workspaces
193    if let Ok(Some((_ws_id, ws_dir, ws_name))) = find_workspace_by_path(&project_path) {
194        if let Ok(sessions) = get_chat_sessions_from_workspace(&ws_dir) {
195            for swp in &sessions {
196                let sid = swp
197                    .session
198                    .session_id
199                    .as_ref()
200                    .map(|s| s.to_lowercase())
201                    .unwrap_or_default();
202                let title = swp.session.title().to_lowercase();
203                let filename = swp
204                    .path
205                    .file_name()
206                    .map(|f| f.to_string_lossy().to_lowercase())
207                    .unwrap_or_default();
208
209                if sid.contains(&session_lower)
210                    || title.contains(&session_lower)
211                    || filename.contains(&session_lower)
212                {
213                    found = true;
214                    println!("\n{} Session Found!", "[+]".green().bold());
215                    println!("   {} Provider: {}", "[*]".blue(), "GitHub Copilot".cyan());
216                    println!("   {} Title: {}", "[*]".blue(), swp.session.title());
217                    println!("   {} File: {}", "[*]".blue(), swp.path.display());
218                    println!(
219                        "   {} Messages: {}",
220                        "[*]".blue(),
221                        swp.session.request_count()
222                    );
223                    if let Some(name) = &ws_name {
224                        println!("   {} Workspace: {}", "[*]".blue(), name);
225                    }
226                    break;
227                }
228            }
229        }
230    }
231
232    // Check other providers
233    if !found {
234        let provider_types = vec![
235            ProviderType::Cursor,
236            ProviderType::Ollama,
237            ProviderType::Jan,
238            ProviderType::Gpt4All,
239            ProviderType::LmStudio,
240        ];
241
242        for provider_type in provider_types {
243            if let Some(provider) = registry.get_provider(provider_type) {
244                if provider.is_available() {
245                    if let Ok(sessions) = provider.list_sessions() {
246                        for session in sessions {
247                            let sid = session
248                                .session_id
249                                .as_ref()
250                                .map(|s| s.to_lowercase())
251                                .unwrap_or_default();
252                            let title = session.title().to_lowercase();
253
254                            if sid.contains(&session_lower) || title.contains(&session_lower) {
255                                found = true;
256                                println!("\n{} Session Found!", "[+]".green().bold());
257                                println!(
258                                    "   {} Provider: {}",
259                                    "[*]".blue(),
260                                    provider.name().cyan()
261                                );
262                                println!("   {} Title: {}", "[*]".blue(), session.title());
263                                println!(
264                                    "   {} Messages: {}",
265                                    "[*]".blue(),
266                                    session.request_count()
267                                );
268                                break;
269                            }
270                        }
271                    }
272                }
273            }
274            if found {
275                break;
276            }
277        }
278    }
279
280    if !found {
281        println!("\n{} Session not found", "[X]".red());
282        println!(
283            "{} Try providing a more specific session ID or check the path",
284            "[i]".yellow()
285        );
286    }
287
288    Ok(())
289}
290
291/// Detect everything (workspace, providers, sessions) for a path
292pub fn detect_all(path: Option<&str>, verbose: bool) -> Result<()> {
293    let project_path = path.map(|p| p.to_string()).unwrap_or_else(|| {
294        std::env::current_dir()
295            .map(|p| p.to_string_lossy().to_string())
296            .unwrap_or_else(|_| ".".to_string())
297    });
298
299    println!("\n{} Auto-Detection Report", "[D]".blue().bold());
300    println!("{}", "=".repeat(70));
301    println!("{} Path: {}", "[*]".blue(), project_path.cyan());
302    println!();
303
304    // 1. Workspace Detection
305    println!("{} Workspace", "---".dimmed());
306    let workspace_info = find_workspace_by_path(&project_path)?;
307
308    match &workspace_info {
309        Some((ws_id, ws_dir, ws_name)) => {
310            println!("   {} Status: {}", "[+]".green(), "Found".green());
311            println!(
312                "   {} ID: {}...",
313                "[*]".blue(),
314                &ws_id[..16.min(ws_id.len())]
315            );
316            if let Some(name) = ws_name {
317                println!("   {} Name: {}", "[*]".blue(), name.cyan());
318            }
319
320            // Get sessions from workspace
321            if let Ok(sessions) = get_chat_sessions_from_workspace(ws_dir) {
322                println!("   {} Sessions: {}", "[*]".blue(), sessions.len());
323
324                if verbose && !sessions.is_empty() {
325                    println!("\n   {} Recent Sessions:", "[*]".blue());
326                    for (i, swp) in sessions.iter().take(5).enumerate() {
327                        println!(
328                            "      {}. {} ({} messages)",
329                            i + 1,
330                            truncate(&swp.session.title(), 40),
331                            swp.session.request_count()
332                        );
333                    }
334                    if sessions.len() > 5 {
335                        println!("      ... and {} more", sessions.len() - 5);
336                    }
337                }
338            }
339        }
340        None => {
341            println!("   {} Status: {}", "[X]".red(), "Not found".red());
342            println!(
343                "   {} Open this project in VS Code to create a workspace",
344                "[i]".yellow()
345            );
346        }
347    }
348    println!();
349
350    // 2. Provider Detection
351    println!("{} Available Providers", "---".dimmed());
352
353    let registry = ProviderRegistry::new();
354    let provider_types = vec![
355        ProviderType::Copilot,
356        ProviderType::Cursor,
357        ProviderType::Ollama,
358        ProviderType::Vllm,
359        ProviderType::Foundry,
360        ProviderType::LmStudio,
361        ProviderType::LocalAI,
362        ProviderType::TextGenWebUI,
363        ProviderType::Jan,
364        ProviderType::Gpt4All,
365        ProviderType::Llamafile,
366    ];
367
368    let mut total_sessions = 0;
369    let mut provider_summary: Vec<(String, usize)> = Vec::new();
370
371    for provider_type in provider_types {
372        if let Some(provider) = registry.get_provider(provider_type) {
373            if provider.is_available() {
374                let session_count = provider.list_sessions().map(|s| s.len()).unwrap_or(0);
375
376                if session_count > 0 || verbose {
377                    let status = if session_count > 0 {
378                        format!("{} sessions", session_count.to_string().cyan())
379                    } else {
380                        "ready".dimmed().to_string()
381                    };
382                    println!("   {} {}: {}", "[+]".green(), provider.name(), status);
383
384                    total_sessions += session_count;
385                    if session_count > 0 {
386                        provider_summary.push((provider.name().to_string(), session_count));
387                    }
388                }
389            }
390        }
391    }
392
393    if provider_summary.is_empty() && !verbose {
394        println!("   {} No providers with sessions found", "[i]".yellow());
395        println!(
396            "   {} Use --verbose to see all available providers",
397            "[i]".dimmed()
398        );
399    }
400    println!();
401
402    // 3. Summary
403    println!("{} Summary", "---".dimmed());
404
405    let ws_status = if workspace_info.is_some() {
406        "Yes".green()
407    } else {
408        "No".red()
409    };
410    println!("   {} Workspace detected: {}", "[*]".blue(), ws_status);
411    println!(
412        "   {} Total providers with sessions: {}",
413        "[*]".blue(),
414        provider_summary.len()
415    );
416    println!(
417        "   {} Total sessions across providers: {}",
418        "[*]".blue(),
419        total_sessions
420    );
421
422    // 4. Recommendations
423    if workspace_info.is_none() || total_sessions == 0 {
424        println!();
425        println!("{} Recommendations", "---".dimmed());
426
427        if workspace_info.is_none() {
428            println!(
429                "   {} Open this project in VS Code to enable chat history tracking",
430                "[->]".cyan()
431            );
432        }
433
434        if total_sessions == 0 {
435            println!(
436                "   {} Start a chat session in your IDE to create history",
437                "[->]".cyan()
438            );
439        }
440    }
441
442    Ok(())
443}
444
445/// Helper function to truncate strings
446fn truncate(s: &str, max_len: usize) -> String {
447    if s.len() <= max_len {
448        s.to_string()
449    } else {
450        format!("{}...", &s[..max_len - 3])
451    }
452}
453
454/// Detect all workspace hashes for a project path (including orphaned workspaces)
455/// This helps find sessions that exist on disk but are in old/orphaned workspace folders
456pub fn detect_orphaned(path: Option<&str>, recover: bool) -> Result<()> {
457    use crate::models::WorkspaceJson;
458    use crate::workspace::{decode_workspace_folder, get_workspace_storage_path, normalize_path};
459
460    let project_path = path.map(|p| p.to_string()).unwrap_or_else(|| {
461        std::env::current_dir()
462            .map(|p| p.to_string_lossy().to_string())
463            .unwrap_or_else(|_| ".".to_string())
464    });
465
466    println!("\n{} Scanning for Orphaned Sessions", "[D]".blue().bold());
467    println!("{}", "=".repeat(60));
468    println!("{} Path: {}", "[*]".blue(), project_path.cyan());
469
470    let storage_path = get_workspace_storage_path()?;
471    let target_path = normalize_path(&project_path);
472
473    // Find ALL workspace hashes that match this path
474    let mut all_workspaces: Vec<(String, std::path::PathBuf, usize, std::time::SystemTime)> =
475        Vec::new();
476
477    for entry in std::fs::read_dir(&storage_path)? {
478        let entry = entry?;
479        let workspace_dir = entry.path();
480
481        if !workspace_dir.is_dir() {
482            continue;
483        }
484
485        let workspace_json_path = workspace_dir.join("workspace.json");
486        if !workspace_json_path.exists() {
487            continue;
488        }
489
490        if let Ok(content) = std::fs::read_to_string(&workspace_json_path) {
491            if let Ok(ws_json) = serde_json::from_str::<WorkspaceJson>(&content) {
492                if let Some(folder) = &ws_json.folder {
493                    let folder_path = decode_workspace_folder(folder);
494                    if normalize_path(&folder_path) == target_path {
495                        // Count sessions in this workspace
496                        let chat_sessions_dir = workspace_dir.join("chatSessions");
497                        let session_count = if chat_sessions_dir.exists() {
498                            std::fs::read_dir(&chat_sessions_dir)
499                                .map(|entries| {
500                                    entries
501                                        .filter_map(|e| e.ok())
502                                        .filter(|e| {
503                                            e.path()
504                                                .extension()
505                                                .map(|ext| ext == "json")
506                                                .unwrap_or(false)
507                                        })
508                                        .count()
509                                })
510                                .unwrap_or(0)
511                        } else {
512                            0
513                        };
514
515                        // Get last modified time
516                        let last_modified = if chat_sessions_dir.exists() {
517                            std::fs::read_dir(&chat_sessions_dir)
518                                .ok()
519                                .and_then(|entries| {
520                                    entries
521                                        .filter_map(|e| e.ok())
522                                        .filter_map(|e| e.metadata().ok())
523                                        .filter_map(|m| m.modified().ok())
524                                        .max()
525                                })
526                                .unwrap_or(std::time::UNIX_EPOCH)
527                        } else {
528                            std::time::UNIX_EPOCH
529                        };
530
531                        all_workspaces.push((
532                            entry.file_name().to_string_lossy().to_string(),
533                            workspace_dir,
534                            session_count,
535                            last_modified,
536                        ));
537                    }
538                }
539            }
540        }
541    }
542
543    if all_workspaces.is_empty() {
544        println!("\n{} No workspaces found for this path", "[X]".red());
545        return Ok(());
546    }
547
548    // Sort by last modified (newest first)
549    all_workspaces.sort_by(|a, b| b.3.cmp(&a.3));
550
551    // The first one (most recently modified) is the "active" workspace
552    let active_dir = all_workspaces[0].1.clone();
553
554    println!(
555        "\n{} Found {} workspace(s) for this path:",
556        "[+]".green().bold(),
557        all_workspaces.len()
558    );
559
560    let mut total_orphaned_sessions = 0;
561    let mut orphaned_workspaces: Vec<(String, std::path::PathBuf, usize)> = Vec::new();
562
563    for (i, (hash, dir, session_count, _)) in all_workspaces.iter().enumerate() {
564        let is_active = i == 0;
565        let status = if is_active {
566            format!("{}", "(active)".green())
567        } else {
568            format!("{}", "(orphaned)".yellow())
569        };
570
571        let session_str = if *session_count > 0 {
572            format!("{} sessions", session_count.to_string().cyan())
573        } else {
574            "0 sessions".dimmed().to_string()
575        };
576
577        println!(
578            "   {} {}... {} - {}",
579            if is_active {
580                "[*]".green()
581            } else {
582                "[!]".yellow()
583            },
584            &hash[..16.min(hash.len())],
585            status,
586            session_str
587        );
588
589        if !is_active && *session_count > 0 {
590            total_orphaned_sessions += session_count;
591            orphaned_workspaces.push((hash.clone(), dir.clone(), *session_count));
592
593            // Show session details
594            let chat_sessions_dir = dir.join("chatSessions");
595            if let Ok(entries) = std::fs::read_dir(&chat_sessions_dir) {
596                for entry in entries.filter_map(|e| e.ok()).take(3) {
597                    let path = entry.path();
598                    if path.extension().map(|e| e == "json").unwrap_or(false) {
599                        if let Ok(content) = std::fs::read_to_string(&path) {
600                            if let Ok(session) = crate::storage::parse_session_json(&content) {
601                                let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
602                                let size_str = if size > 1_000_000 {
603                                    format!("{:.1}MB", size as f64 / 1_000_000.0)
604                                } else if size > 1000 {
605                                    format!("{:.1}KB", size as f64 / 1000.0)
606                                } else {
607                                    format!("{}B", size)
608                                };
609                                println!(
610                                    "      {} {} ({}, {} msgs)",
611                                    "`".dimmed(),
612                                    truncate(&session.title(), 45),
613                                    size_str.cyan(),
614                                    session.request_count()
615                                );
616                            }
617                        }
618                    }
619                }
620            }
621        }
622    }
623
624    // Summary
625    println!();
626    if total_orphaned_sessions > 0 {
627        println!(
628            "{} {} orphaned session(s) found in {} workspace(s)",
629            "[!]".yellow().bold(),
630            total_orphaned_sessions.to_string().yellow(),
631            orphaned_workspaces.len()
632        );
633
634        if recover {
635            // Recover orphaned sessions
636            println!("\n{} Recovering orphaned sessions...", "[*]".blue());
637
638            let active_chat_sessions = active_dir.join("chatSessions");
639            if !active_chat_sessions.exists() {
640                std::fs::create_dir_all(&active_chat_sessions)?;
641            }
642
643            let mut recovered = 0;
644            for (hash, orphan_dir, _) in &orphaned_workspaces {
645                let orphan_sessions = orphan_dir.join("chatSessions");
646                if let Ok(entries) = std::fs::read_dir(&orphan_sessions) {
647                    for entry in entries.filter_map(|e| e.ok()) {
648                        let src = entry.path();
649                        if src.extension().map(|e| e == "json").unwrap_or(false) {
650                            let filename = src.file_name().unwrap();
651                            let dest = active_chat_sessions.join(filename);
652                            if !dest.exists() {
653                                std::fs::copy(&src, &dest)?;
654                                recovered += 1;
655                                println!(
656                                    "   {} Copied: {} (from {}...)",
657                                    "[+]".green(),
658                                    filename.to_string_lossy(),
659                                    &hash[..8]
660                                );
661                            }
662                        }
663                    }
664                }
665            }
666
667            println!(
668                "\n{} Recovered {} session(s)",
669                "[OK]".green().bold(),
670                recovered
671            );
672            println!(
673                "\n{} Run {} to make them visible in VS Code",
674                "[i]".cyan(),
675                "chasm register all --force".cyan()
676            );
677        } else {
678            println!(
679                "\n{} To recover, run: {}",
680                "[->]".cyan(),
681                format!(
682                    "chasm detect orphaned --recover --path \"{}\"",
683                    project_path
684                )
685                .cyan()
686            );
687        }
688    } else {
689        println!("{} No orphaned sessions found", "[OK]".green().bold());
690    }
691
692    Ok(())
693}