1use 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#[derive(Debug, Clone)]
16enum CheckStatus {
17 Pass,
18 Warn(String),
19 Fail(String),
20}
21
22#[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
65pub fn doctor(full: bool, format: &str, fix: bool) -> Result<()> {
67 let mut results: Vec<CheckResult> = Vec::new();
68
69 results.push(check_version());
71 results.push(check_rust_version());
72 results.push(check_os());
73
74 results.push(check_vscode_storage());
76 results.push(check_cursor_storage());
77 results.push(check_harvest_db());
78
79 results.push(check_claude_code());
81 results.push(check_codex_cli());
82 results.push(check_gemini_cli());
83
84 results.push(check_git());
86 results.push(check_sqlite());
87
88 if full {
90 results.push(check_ollama());
91 results.push(check_lm_studio());
92 results.push(check_api_server());
93 }
94
95 let diagnoses = check_all_workspace_sessions(&mut results);
97
98 match format {
100 "json" => print_json(&results),
101 _ => print_text(&results),
102 }
103
104 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 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
248fn 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 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 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
363fn 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
372fn 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 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
590fn 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
651fn 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}