Skip to main content

chasm/commands/
doctor.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: AGPL-3.0-only
3//! `chasm doctor` — Environment diagnostics and health checks
4
5use anyhow::Result;
6use colored::Colorize;
7use std::path::PathBuf;
8
9use crate::storage::{
10    diagnose_workspace_sessions, repair_workspace_sessions, SessionIssueKind, WorkspaceDiagnosis,
11};
12use crate::workspace::discover_workspaces;
13
14/// Status of a single health check
15#[derive(Debug, Clone)]
16enum CheckStatus {
17    Pass,
18    Warn(String),
19    Fail(String),
20}
21
22/// A single diagnostic check result
23#[derive(Debug, Clone)]
24struct CheckResult {
25    name: String,
26    category: String,
27    status: CheckStatus,
28    detail: Option<String>,
29}
30
31impl CheckResult {
32    fn pass(category: &str, name: &str) -> Self {
33        Self {
34            name: name.to_string(),
35            category: category.to_string(),
36            status: CheckStatus::Pass,
37            detail: None,
38        }
39    }
40
41    fn warn(category: &str, name: &str, msg: &str) -> Self {
42        Self {
43            name: name.to_string(),
44            category: category.to_string(),
45            status: CheckStatus::Warn(msg.to_string()),
46            detail: None,
47        }
48    }
49
50    fn fail(category: &str, name: &str, msg: &str) -> Self {
51        Self {
52            name: name.to_string(),
53            category: category.to_string(),
54            status: CheckStatus::Fail(msg.to_string()),
55            detail: None,
56        }
57    }
58
59    fn with_detail(mut self, detail: &str) -> Self {
60        self.detail = Some(detail.to_string());
61        self
62    }
63}
64
65/// Run all diagnostic checks
66pub fn doctor(full: bool, format: &str, fix: bool) -> Result<()> {
67    let mut results: Vec<CheckResult> = Vec::new();
68
69    // ── System checks ──────────────────────────────────────────────
70    results.push(check_version());
71    results.push(check_rust_version());
72    results.push(check_os());
73
74    // ── Storage checks ─────────────────────────────────────────────
75    results.push(check_vscode_storage());
76    results.push(check_cursor_storage());
77    results.push(check_harvest_db());
78
79    // ── Provider checks ────────────────────────────────────────────
80    results.push(check_claude_code());
81    results.push(check_codex_cli());
82    results.push(check_gemini_cli());
83
84    // ── Tool checks ────────────────────────────────────────────────
85    results.push(check_git());
86    results.push(check_sqlite());
87
88    // ── Network checks (only with --full) ──────────────────────────
89    if full {
90        results.push(check_ollama());
91        results.push(check_lm_studio());
92        results.push(check_api_server());
93    }
94
95    // ── Session health checks (always run) ─────────────────────────
96    let diagnoses = check_all_workspace_sessions(&mut results);
97
98    // ── Output ─────────────────────────────────────────────────────
99    match format {
100        "json" => print_json(&results),
101        _ => print_text(&results),
102    }
103
104    // Summary
105    let pass_count = results
106        .iter()
107        .filter(|r| matches!(r.status, CheckStatus::Pass))
108        .count();
109    let warn_count = results
110        .iter()
111        .filter(|r| matches!(r.status, CheckStatus::Warn(_)))
112        .count();
113    let fail_count = results
114        .iter()
115        .filter(|r| matches!(r.status, CheckStatus::Fail(_)))
116        .count();
117
118    if format != "json" {
119        println!();
120        println!(
121            "  {} {} passed, {} warnings, {} failures",
122            "Summary:".bold(),
123            pass_count.to_string().green(),
124            warn_count.to_string().yellow(),
125            fail_count.to_string().red(),
126        );
127
128        if !full {
129            println!(
130                "  {} Run {} for network connectivity checks",
131                "Tip:".bright_black(),
132                "chasm doctor --full".cyan(),
133            );
134        }
135    }
136
137    // ── Auto-fix with --fix ────────────────────────────────────────
138    if fix {
139        let unhealthy: Vec<&WorkspaceDiagnosis> =
140            diagnoses.iter().filter(|d| !d.is_healthy()).collect();
141
142        if unhealthy.is_empty() {
143            if format != "json" {
144                println!(
145                    "\n  {} All workspaces are healthy — nothing to fix.",
146                    "✓".green()
147                );
148            }
149        } else {
150            if format != "json" {
151                println!(
152                    "\n  {} Auto-fixing {} workspace(s) with issues...\n",
153                    "[FIX]".cyan().bold(),
154                    unhealthy.len()
155                );
156            }
157
158            let mut total_compacted = 0usize;
159            let mut total_synced = 0usize;
160            let mut succeeded = 0usize;
161            let mut failed = 0usize;
162
163            for diag in &unhealthy {
164                let display_name = diag.project_path.as_deref().unwrap_or(&diag.workspace_hash);
165
166                if format != "json" {
167                    let issue_kinds: Vec<String> = {
168                        let mut kinds: Vec<String> = Vec::new();
169                        for issue in &diag.issues {
170                            let s = format!("{}", issue.kind);
171                            if !kinds.contains(&s) {
172                                kinds.push(s);
173                            }
174                        }
175                        kinds
176                    };
177                    println!(
178                        "  {} {} ({} issue{}): {}",
179                        "[*]".yellow(),
180                        display_name.cyan(),
181                        diag.issues.len(),
182                        if diag.issues.len() == 1 { "" } else { "s" },
183                        issue_kinds.join(", ")
184                    );
185                }
186
187                let chat_sessions_dir = PathBuf::from(
188                    get_vscode_storage_path()
189                        .unwrap_or_default()
190                        .join(&diag.workspace_hash)
191                        .join("chatSessions"),
192                );
193
194                match repair_workspace_sessions(&diag.workspace_hash, &chat_sessions_dir, true) {
195                    Ok((compacted, synced)) => {
196                        total_compacted += compacted;
197                        total_synced += synced;
198                        succeeded += 1;
199                        if format != "json" {
200                            println!(
201                                "      {} {} compacted, {} index entries synced",
202                                "[OK]".green(),
203                                compacted,
204                                synced
205                            );
206                        }
207                    }
208                    Err(e) => {
209                        failed += 1;
210                        if format != "json" {
211                            println!("      {} {}", "[ERR]".red(), e);
212                        }
213                    }
214                }
215            }
216
217            if format != "json" {
218                println!(
219                    "\n  {} Auto-fix complete: {}/{} workspaces repaired, {} compacted, {} synced",
220                    "[OK]".green().bold(),
221                    succeeded.to_string().green(),
222                    unhealthy.len(),
223                    total_compacted.to_string().cyan(),
224                    total_synced.to_string().cyan()
225                );
226                if failed > 0 {
227                    println!(
228                        "  {} {} workspace(s) had errors",
229                        "[!]".yellow(),
230                        failed.to_string().red()
231                    );
232                }
233            }
234        }
235    } else if diagnoses.iter().any(|d| !d.is_healthy()) && format != "json" {
236        let total_issues: usize = diagnoses.iter().map(|d| d.issues.len()).sum();
237        println!(
238            "  {} Run {} to automatically fix {} issue(s)",
239            "Tip:".bright_black(),
240            "chasm doctor --fix".cyan(),
241            total_issues.to_string().yellow(),
242        );
243    }
244
245    Ok(())
246}
247
248// ─── Session health check ──────────────────────────────────────────
249
250/// Scan all VS Code workspaces for session issues and add results to the check list.
251/// Returns the full diagnosis list for use by --fix.
252fn check_all_workspace_sessions(results: &mut Vec<CheckResult>) -> Vec<WorkspaceDiagnosis> {
253    let workspaces = match discover_workspaces() {
254        Ok(ws) => ws,
255        Err(e) => {
256            results.push(CheckResult::fail(
257                "sessions",
258                "Workspace scan",
259                &format!("Failed to discover workspaces: {}", e),
260            ));
261            return Vec::new();
262        }
263    };
264
265    let ws_with_sessions: Vec<_> = workspaces
266        .iter()
267        .filter(|w| w.has_chat_sessions && w.chat_session_count > 0)
268        .collect();
269
270    if ws_with_sessions.is_empty() {
271        results.push(
272            CheckResult::pass("sessions", "Session health")
273                .with_detail("No workspaces with chat sessions found"),
274        );
275        return Vec::new();
276    }
277
278    let mut diagnoses = Vec::new();
279    let mut total_issues = 0usize;
280    let mut workspaces_with_issues = 0usize;
281    let mut issue_counts: std::collections::HashMap<String, usize> =
282        std::collections::HashMap::new();
283
284    for ws in &ws_with_sessions {
285        let chat_dir = ws.workspace_path.join("chatSessions");
286        match diagnose_workspace_sessions(&ws.hash, &chat_dir) {
287            Ok(mut diag) => {
288                diag.project_path = ws.project_path.clone();
289                if !diag.is_healthy() {
290                    workspaces_with_issues += 1;
291                    for issue in &diag.issues {
292                        total_issues += 1;
293                        *issue_counts.entry(format!("{}", issue.kind)).or_default() += 1;
294                    }
295                }
296                diagnoses.push(diag);
297            }
298            Err(e) => {
299                let display = ws.project_path.as_deref().unwrap_or(&ws.hash);
300                results.push(CheckResult::warn(
301                    "sessions",
302                    &format!("Scan: {}", display),
303                    &format!("Failed: {}", e),
304                ));
305            }
306        }
307    }
308
309    if total_issues == 0 {
310        results.push(
311            CheckResult::pass("sessions", "Session health").with_detail(&format!(
312                "All {} workspace(s) with sessions are healthy",
313                ws_with_sessions.len()
314            )),
315        );
316    } else {
317        // Add one summary result
318        let breakdown: Vec<String> = issue_counts
319            .iter()
320            .map(|(kind, count)| format!("{count} {kind}"))
321            .collect();
322
323        results.push(CheckResult::fail(
324            "sessions",
325            "Session health",
326            &format!(
327                "{} issue(s) in {}/{} workspace(s): {}",
328                total_issues,
329                workspaces_with_issues,
330                ws_with_sessions.len(),
331                breakdown.join(", ")
332            ),
333        ));
334
335        // Add per-workspace detail results for unhealthy workspaces
336        for diag in &diagnoses {
337            if !diag.is_healthy() {
338                let display = diag.project_path.as_deref().unwrap_or(&diag.workspace_hash);
339                let issue_summary: Vec<String> = diag
340                    .issues
341                    .iter()
342                    .map(|i| {
343                        format!(
344                            "{}: {}",
345                            i.session_id[..8.min(i.session_id.len())].to_string(),
346                            i.kind
347                        )
348                    })
349                    .collect();
350
351                results.push(CheckResult::warn(
352                    "sessions",
353                    &format!("  {}", truncate_path(display, 45)),
354                    &format!("{}", issue_summary.join("; ")),
355                ));
356            }
357        }
358    }
359
360    diagnoses
361}
362
363/// Truncate a path for display, keeping the last N characters
364fn truncate_path(path: &str, max_len: usize) -> String {
365    if path.len() <= max_len {
366        path.to_string()
367    } else {
368        format!("...{}", &path[path.len() - max_len + 3..])
369    }
370}
371
372// ─── Check implementations ─────────────────────────────────────────
373
374fn check_version() -> CheckResult {
375    let version = env!("CARGO_PKG_VERSION");
376    CheckResult::pass("system", "Chasm version").with_detail(&format!("v{version}"))
377}
378
379fn check_rust_version() -> CheckResult {
380    let msrv = "1.75";
381    CheckResult::pass("system", "Minimum Rust version").with_detail(&format!("MSRV {msrv}"))
382}
383
384fn check_os() -> CheckResult {
385    let os = std::env::consts::OS;
386    let arch = std::env::consts::ARCH;
387    CheckResult::pass("system", "Operating system").with_detail(&format!("{os}/{arch}"))
388}
389
390fn check_vscode_storage() -> CheckResult {
391    let path = get_vscode_storage_path();
392    match path {
393        Some(p) if p.exists() => {
394            let count = count_workspaces(&p);
395            CheckResult::pass("storage", "VS Code workspace storage").with_detail(&format!(
396                "{} workspaces found at {}",
397                count,
398                p.display()
399            ))
400        }
401        Some(p) => CheckResult::warn(
402            "storage",
403            "VS Code workspace storage",
404            &format!("Path not found: {}", p.display()),
405        ),
406        None => CheckResult::warn(
407            "storage",
408            "VS Code workspace storage",
409            "Could not determine default path",
410        ),
411    }
412}
413
414fn check_cursor_storage() -> CheckResult {
415    let path = get_cursor_storage_path();
416    match path {
417        Some(p) if p.exists() => {
418            let count = count_workspaces(&p);
419            CheckResult::pass("storage", "Cursor workspace storage").with_detail(&format!(
420                "{} workspaces found at {}",
421                count,
422                p.display()
423            ))
424        }
425        Some(p) => CheckResult::pass("storage", "Cursor workspace storage")
426            .with_detail(&format!("Not installed ({})", p.display())),
427        None => {
428            CheckResult::pass("storage", "Cursor workspace storage").with_detail("Not installed")
429        }
430    }
431}
432
433fn check_harvest_db() -> CheckResult {
434    let db_path = get_harvest_db_path();
435    match db_path {
436        Some(p) if p.exists() => {
437            let size = std::fs::metadata(&p)
438                .map(|m| format_bytes(m.len()))
439                .unwrap_or_else(|_| "unknown size".to_string());
440            CheckResult::pass("storage", "Harvest database").with_detail(&format!(
441                "{} at {}",
442                size,
443                p.display()
444            ))
445        }
446        Some(p) => CheckResult::warn(
447            "storage",
448            "Harvest database",
449            &format!(
450                "Not found at {}. Run `chasm harvest run` to create it.",
451                p.display()
452            ),
453        ),
454        None => CheckResult::warn("storage", "Harvest database", "Could not determine path"),
455    }
456}
457
458fn check_claude_code() -> CheckResult {
459    let home = dirs::home_dir();
460    match home {
461        Some(h) => {
462            let claude_dir = h.join(".claude");
463            if claude_dir.exists() {
464                CheckResult::pass("provider", "Claude Code")
465                    .with_detail(&format!("Detected at {}", claude_dir.display()))
466            } else {
467                CheckResult::pass("provider", "Claude Code").with_detail("Not installed")
468            }
469        }
470        None => CheckResult::warn(
471            "provider",
472            "Claude Code",
473            "Could not determine home directory",
474        ),
475    }
476}
477
478fn check_codex_cli() -> CheckResult {
479    let home = dirs::home_dir();
480    match home {
481        Some(h) => {
482            let codex_dir = h.join(".codex");
483            if codex_dir.exists() {
484                CheckResult::pass("provider", "Codex CLI")
485                    .with_detail(&format!("Detected at {}", codex_dir.display()))
486            } else {
487                CheckResult::pass("provider", "Codex CLI").with_detail("Not installed")
488            }
489        }
490        None => CheckResult::warn(
491            "provider",
492            "Codex CLI",
493            "Could not determine home directory",
494        ),
495    }
496}
497
498fn check_gemini_cli() -> CheckResult {
499    let home = dirs::home_dir();
500    match home {
501        Some(h) => {
502            let gemini_dir = h.join(".gemini");
503            if gemini_dir.exists() {
504                CheckResult::pass("provider", "Gemini CLI")
505                    .with_detail(&format!("Detected at {}", gemini_dir.display()))
506            } else {
507                CheckResult::pass("provider", "Gemini CLI").with_detail("Not installed")
508            }
509        }
510        None => CheckResult::warn(
511            "provider",
512            "Gemini CLI",
513            "Could not determine home directory",
514        ),
515    }
516}
517
518fn check_git() -> CheckResult {
519    match std::process::Command::new("git").arg("--version").output() {
520        Ok(output) if output.status.success() => {
521            let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
522            CheckResult::pass("tools", "Git").with_detail(&version)
523        }
524        _ => CheckResult::warn(
525            "tools",
526            "Git",
527            "Not found in PATH (optional, needed for `chasm git`)",
528        ),
529    }
530}
531
532fn check_sqlite() -> CheckResult {
533    // We use bundled rusqlite, so this always passes
534    CheckResult::pass("tools", "SQLite (bundled)").with_detail("rusqlite with bundled SQLite")
535}
536
537fn check_ollama() -> CheckResult {
538    let url = std::env::var("OLLAMA_HOST").unwrap_or_else(|_| "http://localhost:11434".to_string());
539
540    match reqwest::blocking::Client::new()
541        .get(format!("{url}/api/tags"))
542        .timeout(std::time::Duration::from_secs(3))
543        .send()
544    {
545        Ok(resp) if resp.status().is_success() => {
546            CheckResult::pass("network", "Ollama").with_detail(&format!("Running at {url}"))
547        }
548        Ok(resp) => CheckResult::warn(
549            "network",
550            "Ollama",
551            &format!("Responded with status {} at {url}", resp.status()),
552        ),
553        Err(_) => {
554            CheckResult::pass("network", "Ollama").with_detail(&format!("Not running at {url}"))
555        }
556    }
557}
558
559fn check_lm_studio() -> CheckResult {
560    let url =
561        std::env::var("LM_STUDIO_URL").unwrap_or_else(|_| "http://localhost:1234".to_string());
562
563    match reqwest::blocking::Client::new()
564        .get(format!("{url}/v1/models"))
565        .timeout(std::time::Duration::from_secs(3))
566        .send()
567    {
568        Ok(resp) if resp.status().is_success() => {
569            CheckResult::pass("network", "LM Studio").with_detail(&format!("Running at {url}"))
570        }
571        _ => {
572            CheckResult::pass("network", "LM Studio").with_detail(&format!("Not running at {url}"))
573        }
574    }
575}
576
577fn check_api_server() -> CheckResult {
578    match reqwest::blocking::Client::new()
579        .get("http://localhost:8787/api/health")
580        .timeout(std::time::Duration::from_secs(3))
581        .send()
582    {
583        Ok(resp) if resp.status().is_success() => CheckResult::pass("network", "Chasm API server")
584            .with_detail("Running at http://localhost:8787"),
585        _ => CheckResult::pass("network", "Chasm API server")
586            .with_detail("Not running (start with `chasm api serve`)"),
587    }
588}
589
590// ─── Output formatting ─────────────────────────────────────────────
591
592fn print_text(results: &[CheckResult]) {
593    println!();
594    println!("  {}", "Chasm Doctor".bold().cyan());
595    println!("  {}", "─".repeat(50).bright_black());
596
597    let mut current_category = String::new();
598
599    for result in results {
600        if result.category != current_category {
601            current_category = result.category.clone();
602            println!();
603            println!(
604                "  {} {}",
605                "▸".bright_black(),
606                current_category.to_uppercase().bold()
607            );
608        }
609
610        let (icon, msg) = match &result.status {
611            CheckStatus::Pass => ("✓".green(), String::new()),
612            CheckStatus::Warn(m) => ("!".yellow(), format!(" — {}", m.yellow())),
613            CheckStatus::Fail(m) => ("✗".red(), format!(" — {}", m.red())),
614        };
615
616        let detail = result
617            .detail
618            .as_ref()
619            .map(|d| format!(" {}", d.bright_black()))
620            .unwrap_or_default();
621
622        println!("    {} {}{}{}", icon, result.name, detail, msg);
623    }
624}
625
626fn print_json(results: &[CheckResult]) {
627    let json_results: Vec<serde_json::Value> = results
628        .iter()
629        .map(|r| {
630            let (status, message) = match &r.status {
631                CheckStatus::Pass => ("pass", None),
632                CheckStatus::Warn(m) => ("warn", Some(m.as_str())),
633                CheckStatus::Fail(m) => ("fail", Some(m.as_str())),
634            };
635            serde_json::json!({
636                "category": r.category,
637                "name": r.name,
638                "status": status,
639                "message": message,
640                "detail": r.detail,
641            })
642        })
643        .collect();
644
645    println!(
646        "{}",
647        serde_json::to_string_pretty(&json_results).unwrap_or_default()
648    );
649}
650
651// ─── Helpers ────────────────────────────────────────────────────────
652
653fn get_vscode_storage_path() -> Option<PathBuf> {
654    #[cfg(target_os = "windows")]
655    {
656        dirs::config_dir().map(|p| p.join("Code").join("User").join("workspaceStorage"))
657    }
658    #[cfg(target_os = "macos")]
659    {
660        dirs::home_dir().map(|p| {
661            p.join("Library")
662                .join("Application Support")
663                .join("Code")
664                .join("User")
665                .join("workspaceStorage")
666        })
667    }
668    #[cfg(target_os = "linux")]
669    {
670        dirs::config_dir().map(|p| p.join("Code").join("User").join("workspaceStorage"))
671    }
672}
673
674fn get_cursor_storage_path() -> Option<PathBuf> {
675    #[cfg(target_os = "windows")]
676    {
677        dirs::config_dir().map(|p| p.join("Cursor").join("User").join("workspaceStorage"))
678    }
679    #[cfg(target_os = "macos")]
680    {
681        dirs::home_dir().map(|p| {
682            p.join("Library")
683                .join("Application Support")
684                .join("Cursor")
685                .join("User")
686                .join("workspaceStorage")
687        })
688    }
689    #[cfg(target_os = "linux")]
690    {
691        dirs::config_dir().map(|p| p.join("Cursor").join("User").join("workspaceStorage"))
692    }
693}
694
695fn get_harvest_db_path() -> Option<PathBuf> {
696    dirs::data_dir().map(|p| p.join("chasm").join("harvest.db"))
697}
698
699fn count_workspaces(path: &PathBuf) -> usize {
700    std::fs::read_dir(path)
701        .map(|entries| {
702            entries
703                .filter_map(|e| e.ok())
704                .filter(|e| e.path().is_dir())
705                .count()
706        })
707        .unwrap_or(0)
708}
709
710fn format_bytes(bytes: u64) -> String {
711    if bytes < 1024 {
712        format!("{bytes} B")
713    } else if bytes < 1024 * 1024 {
714        format!("{:.1} KB", bytes as f64 / 1024.0)
715    } else if bytes < 1024 * 1024 * 1024 {
716        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
717    } else {
718        format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
719    }
720}