Skip to main content

cortex_runtime/cli/
doctor.rs

1//! Environment readiness check — the single most important command.
2//!
3//! Performs 14 diagnostic checks covering system, browser, runtime, cache, and
4//! optional toolchain presence. Every failure includes a specific fix instruction.
5
6use crate::cli::output::{self, Styled};
7use crate::cli::start::{pid_file_path, SOCKET_PATH};
8use anyhow::Result;
9use std::path::PathBuf;
10use std::process::Command;
11
12/// Run the full 14-check doctor diagnostic.
13pub async fn run() -> Result<()> {
14    if output::is_json() {
15        return run_json().await;
16    }
17
18    let s = Styled::new();
19    let mut ready = true;
20    let mut has_warning = false;
21
22    // Header
23    output::print_header(&s);
24
25    // ── System ──────────────────────────────────────────────────────────
26    output::print_section(&s, "System");
27
28    // 1. OS / architecture
29    let os = format_os();
30    let arch = std::env::consts::ARCH;
31    output::print_check(s.ok_sym(), "OS:", &format!("{os} ({arch})"));
32
33    // 2-3. Memory
34    let (total_mb, avail_mb) = get_memory_mb();
35    match avail_mb {
36        Some(a) if a >= 256 => {
37            let display = if let Some(t) = total_mb {
38                format!(
39                    "{:.1} GB total, {:.1} GB available",
40                    t as f64 / 1024.0,
41                    a as f64 / 1024.0
42                )
43            } else {
44                format!("{:.1} GB available", a as f64 / 1024.0)
45            };
46            output::print_check(s.ok_sym(), "Memory:", &display);
47        }
48        Some(a) => {
49            output::print_check(
50                s.warn_sym(),
51                "Memory:",
52                &format!("{a} MB available (recommend >= 256 MB)"),
53            );
54            has_warning = true;
55        }
56        None => {
57            output::print_check(s.warn_sym(), "Memory:", "could not determine");
58            has_warning = true;
59        }
60    }
61
62    // 11. Disk space
63    let cortex_dir = cortex_home();
64    match get_free_disk_mb(&cortex_dir) {
65        Some(free_mb) if free_mb >= 100 => {
66            output::print_check(
67                s.ok_sym(),
68                "Disk:",
69                &format!(
70                    "{} free at {}",
71                    output::format_size(free_mb * 1_048_576),
72                    cortex_dir.display()
73                ),
74            );
75        }
76        Some(free_mb) => {
77            output::print_check(
78                s.fail_sym(),
79                "Disk:",
80                &format!("{free_mb} MB free (< 100 MB minimum)"),
81            );
82            output::print_detail("Free up disk space or change CORTEX_HOME.");
83            ready = false;
84        }
85        None => {
86            output::print_check(s.warn_sym(), "Disk:", "could not determine free space");
87            has_warning = true;
88        }
89    }
90
91    eprintln!();
92
93    // ── Browser ─────────────────────────────────────────────────────────
94    output::print_section(&s, "Browser");
95
96    // 4-5. Chromium installed + version
97    let chromium_path = find_chromium();
98    match &chromium_path {
99        Some(path) => {
100            let version = get_chromium_version(path);
101            let ver_str = version.as_deref().unwrap_or("unknown version");
102            output::print_check(
103                s.ok_sym(),
104                "Chromium:",
105                &format!("{ver_str} at {}", path.display()),
106            );
107
108            // 6. Headless launch test
109            match test_headless_launch(path) {
110                Ok(ms) => {
111                    output::print_check(
112                        s.ok_sym(),
113                        "Headless test:",
114                        &format!("launched and closed in {ms}ms"),
115                    );
116                }
117                Err(e) => {
118                    let msg = e.to_string();
119                    output::print_check(s.fail_sym(), "Headless test:", &format!("FAILED — {msg}"));
120                    if msg.contains("shared librar") || msg.contains("libnss") {
121                        suggest_shared_libs();
122                    }
123                    if is_docker() {
124                        output::print_detail("Running in Docker? Try CORTEX_CHROMIUM_NO_SANDBOX=1");
125                    }
126                    ready = false;
127                }
128            }
129        }
130        None => {
131            output::print_check(s.fail_sym(), "Chromium:", "NOT FOUND");
132            output::print_detail("Fix: run 'cortex install'");
133            output::print_detail("Or set CORTEX_CHROMIUM_PATH=/path/to/chrome");
134            ready = false;
135        }
136    }
137
138    // 7. Shared libraries (Linux only)
139    #[cfg(target_os = "linux")]
140    check_shared_libs(&s, &mut ready);
141
142    // 7b. musl libc detection (Alpine Linux)
143    #[cfg(target_os = "linux")]
144    {
145        if is_musl_libc() {
146            output::print_check(
147                s.warn_sym(),
148                "C library:",
149                "musl libc detected (Alpine Linux)",
150            );
151            output::print_detail("Chromium does not run natively on musl. Install gcompat:");
152            output::print_detail("  apk add gcompat");
153        }
154    }
155
156    eprintln!();
157
158    // ── Runtime ─────────────────────────────────────────────────────────
159    output::print_section(&s, "Runtime");
160
161    // 8. Socket path writable
162    let socket_path = PathBuf::from(SOCKET_PATH);
163    let socket_dir = socket_path.parent().unwrap_or(&socket_path);
164    if socket_dir.exists() {
165        output::print_check(
166            s.ok_sym(),
167            "Socket path:",
168            &format!("{SOCKET_PATH} (writable)"),
169        );
170    } else {
171        output::print_check(
172            s.fail_sym(),
173            "Socket path:",
174            &format!("directory {} does not exist", socket_dir.display()),
175        );
176        ready = false;
177    }
178
179    // 9-10. Process status
180    let pid_path = pid_file_path();
181    let process_status = check_process_status(&pid_path, SOCKET_PATH);
182    match &process_status {
183        ProcessStatus::RunningResponding(pid) => {
184            output::print_check(s.ok_sym(), "Process:", &format!("running (PID {pid})"));
185        }
186        ProcessStatus::RunningNotResponding(pid) => {
187            output::print_check(
188                s.warn_sym(),
189                "Process:",
190                &format!("running (PID {pid}) but not responding on socket"),
191            );
192            output::print_detail("This usually means a crashed process.");
193            output::print_detail("Fix: run 'cortex stop' then 'cortex start'");
194            has_warning = true;
195        }
196        ProcessStatus::StalePid(pid) => {
197            output::print_check(
198                s.warn_sym(),
199                "Process:",
200                &format!("stale PID file (PID {pid} is dead)"),
201            );
202            output::print_detail("Fix: run 'cortex start' (will clean up automatically)");
203            has_warning = true;
204            // Clean up stale PID
205            let _ = std::fs::remove_file(&pid_path);
206        }
207        ProcessStatus::NotRunning => {
208            output::print_check(s.fail_sym(), "Process:", "not running");
209        }
210        ProcessStatus::SocketConflict => {
211            output::print_check(s.fail_sym(), "Process:", "socket in use by another process");
212            output::print_detail(&format!("Another process is listening on {SOCKET_PATH}"));
213            output::print_detail("Remove the socket file or choose a different path.");
214            ready = false;
215        }
216    }
217
218    eprintln!();
219
220    // ── Cache ───────────────────────────────────────────────────────────
221    output::print_section(&s, "Cache");
222
223    // 14. Cached maps
224    let maps_dir = cortex_home().join("maps");
225    let (map_count, map_names, total_size) = scan_cached_maps(&maps_dir);
226    if map_count > 0 {
227        let names = if map_names.len() <= 5 {
228            map_names.join(", ")
229        } else {
230            format!(
231                "{}, ... (+{} more)",
232                map_names[..5].join(", "),
233                map_names.len() - 5
234            )
235        };
236        output::print_check(
237            s.info_sym(),
238            "Maps cached:",
239            &format!("{map_count} ({names})"),
240        );
241        output::print_check(
242            s.info_sym(),
243            "Cache size:",
244            &output::format_size(total_size),
245        );
246    } else {
247        output::print_check(s.info_sym(), "Maps cached:", "none");
248    }
249
250    eprintln!();
251
252    // ── Optional ────────────────────────────────────────────────────────
253    output::print_section(&s, "Optional");
254
255    // 12. Node.js
256    match check_tool_version("node", &["--version"]) {
257        Some(ver) => output::print_check(
258            s.ok_sym(),
259            "Node.js:",
260            &format!("{ver} (for extractor development)"),
261        ),
262        None => output::print_check(
263            s.info_sym(),
264            "Node.js:",
265            "not found (only needed for custom extractors)",
266        ),
267    }
268
269    // 13. Python
270    match check_tool_version("python3", &["--version"]) {
271        Some(ver) => {
272            output::print_check(s.ok_sym(), "Python:", &format!("{ver} (for cortex-client)"))
273        }
274        None => output::print_check(
275            s.info_sym(),
276            "Python:",
277            "not found (only needed for Python client)",
278        ),
279    }
280
281    // Status summary
282    if ready && !has_warning {
283        output::print_status(&s, &s.green("READY"), "start with 'cortex start'");
284    } else if ready && has_warning {
285        output::print_status(&s, &s.yellow("READY"), "some warnings above");
286    } else {
287        output::print_status(&s, &s.red("NOT READY"), "fix issues above");
288    }
289
290    Ok(())
291}
292
293/// JSON output mode for doctor.
294async fn run_json() -> Result<()> {
295    let chromium_path = find_chromium();
296    let chromium_version = chromium_path.as_ref().and_then(get_chromium_version);
297    let (total_mb, avail_mb) = get_memory_mb();
298    let pid_path = pid_file_path();
299    let process = check_process_status(&pid_path, SOCKET_PATH);
300    let maps_dir = cortex_home().join("maps");
301    let (map_count, map_names, total_size) = scan_cached_maps(&maps_dir);
302    let node_ver = check_tool_version("node", &["--version"]);
303    let python_ver = check_tool_version("python3", &["--version"]);
304
305    let json = serde_json::json!({
306        "version": env!("CARGO_PKG_VERSION"),
307        "os": std::env::consts::OS,
308        "arch": std::env::consts::ARCH,
309        "memory_total_mb": total_mb,
310        "memory_available_mb": avail_mb,
311        "chromium_path": chromium_path.map(|p| p.display().to_string()),
312        "chromium_version": chromium_version,
313        "socket_path": SOCKET_PATH,
314        "process_status": format!("{process:?}"),
315        "maps_cached": map_count,
316        "map_names": map_names,
317        "cache_size_bytes": total_size,
318        "node_version": node_ver,
319        "python_version": python_ver,
320    });
321    output::print_json(&json);
322    Ok(())
323}
324
325// ── Helper functions ────────────────────────────────────────────────────────
326
327/// Format OS name nicely.
328fn format_os() -> String {
329    match std::env::consts::OS {
330        "macos" => {
331            if let Ok(out) = Command::new("sw_vers").arg("-productVersion").output() {
332                if out.status.success() {
333                    let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
334                    return format!("macOS {ver}");
335                }
336            }
337            "macOS".to_string()
338        }
339        "linux" => {
340            if let Ok(contents) = std::fs::read_to_string("/etc/os-release") {
341                for line in contents.lines() {
342                    if let Some(name) = line.strip_prefix("PRETTY_NAME=") {
343                        return name.trim_matches('"').to_string();
344                    }
345                }
346            }
347            "Linux".to_string()
348        }
349        other => other.to_string(),
350    }
351}
352
353/// Get the Cortex home directory (~/.cortex/).
354pub fn cortex_home() -> PathBuf {
355    if let Ok(p) = std::env::var("CORTEX_HOME") {
356        return PathBuf::from(p);
357    }
358    dirs::home_dir()
359        .unwrap_or_else(|| PathBuf::from("/tmp"))
360        .join(".cortex")
361}
362
363/// Find Chromium binary by checking multiple locations.
364pub fn find_chromium() -> Option<PathBuf> {
365    // 1. Check CORTEX_CHROMIUM_PATH env
366    if let Ok(p) = std::env::var("CORTEX_CHROMIUM_PATH") {
367        let path = PathBuf::from(&p);
368        if path.exists() {
369            return Some(path);
370        }
371    }
372
373    // 2. Check ~/.cortex/chromium/
374    if let Some(home) = dirs::home_dir() {
375        let candidates = if cfg!(target_os = "macos") {
376            vec![
377                home.join(".cortex/chromium/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"),
378                home.join(".cortex/chromium/chrome"),
379            ]
380        } else {
381            vec![
382                home.join(".cortex/chromium/chrome"),
383                home.join(".cortex/chromium/chrome-linux64/chrome"),
384            ]
385        };
386        for c in candidates {
387            if c.exists() {
388                return Some(c);
389            }
390        }
391    }
392
393    // 3. Check system PATH
394    for name in &["google-chrome", "chromium", "chromium-browser"] {
395        if let Ok(output) = Command::new("which").arg(name).output() {
396            if output.status.success() {
397                let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
398                if !path_str.is_empty() {
399                    return Some(PathBuf::from(path_str));
400                }
401            }
402        }
403    }
404
405    // 4. Common macOS locations
406    if cfg!(target_os = "macos") {
407        let common = PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
408        if common.exists() {
409            return Some(common);
410        }
411    }
412
413    None
414}
415
416/// Get Chromium version string.
417fn get_chromium_version(path: &PathBuf) -> Option<String> {
418    let output = Command::new(path).arg("--version").output().ok()?;
419    if output.status.success() {
420        let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
421        Some(raw.replace("Google Chrome ", "").replace("Chromium ", ""))
422    } else {
423        None
424    }
425}
426
427/// Test that Chromium can launch headless and close.
428fn test_headless_launch(chromium_path: &PathBuf) -> Result<u64> {
429    let start = std::time::Instant::now();
430    let mut cmd = Command::new(chromium_path);
431    cmd.args(["--headless", "--disable-gpu", "--dump-dom", "about:blank"]);
432
433    if is_docker() || std::env::var("CORTEX_CHROMIUM_NO_SANDBOX").is_ok() {
434        cmd.arg("--no-sandbox");
435    }
436
437    let output = cmd
438        .output()
439        .map_err(|e| anyhow::anyhow!("failed to launch: {e}"))?;
440
441    if !output.status.success() {
442        let stderr = String::from_utf8_lossy(&output.stderr);
443        return Err(anyhow::anyhow!(
444            "{}",
445            stderr.lines().next().unwrap_or("unknown error")
446        ));
447    }
448
449    Ok(start.elapsed().as_millis() as u64)
450}
451
452/// Get total and available memory in MB.
453fn get_memory_mb() -> (Option<u64>, Option<u64>) {
454    #[cfg(target_os = "macos")]
455    {
456        let total = Command::new("sysctl")
457            .args(["-n", "hw.memsize"])
458            .output()
459            .ok()
460            .and_then(|o| {
461                String::from_utf8_lossy(&o.stdout)
462                    .trim()
463                    .parse::<u64>()
464                    .ok()
465            })
466            .map(|b| b / 1_048_576);
467
468        let avail = Command::new("vm_stat").output().ok().and_then(|o| {
469            let s = String::from_utf8_lossy(&o.stdout);
470            let mut free = 0u64;
471            for line in s.lines() {
472                if line.starts_with("Pages free") || line.starts_with("Pages inactive") {
473                    if let Some(val) = line.split(':').nth(1) {
474                        if let Ok(n) = val.trim().trim_end_matches('.').parse::<u64>() {
475                            free += n * 4096;
476                        }
477                    }
478                }
479            }
480            if free > 0 {
481                Some(free / 1_048_576)
482            } else {
483                total
484            }
485        });
486
487        (total, avail)
488    }
489
490    #[cfg(target_os = "linux")]
491    {
492        let output = Command::new("free").args(["-m"]).output().ok();
493        if let Some(out) = output {
494            let s = String::from_utf8_lossy(&out.stdout);
495            for line in s.lines() {
496                if line.starts_with("Mem:") {
497                    let parts: Vec<&str> = line.split_whitespace().collect();
498                    let total = parts.get(1).and_then(|v| v.parse().ok());
499                    let avail = parts.get(6).and_then(|v| v.parse().ok());
500                    return (total, avail);
501                }
502            }
503        }
504        (None, None)
505    }
506
507    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
508    {
509        (None, None)
510    }
511}
512
513/// Get free disk space in MB at a given path.
514fn get_free_disk_mb(path: &std::path::Path) -> Option<u64> {
515    let check_path = if path.exists() {
516        path.to_path_buf()
517    } else if let Some(parent) = path.parent() {
518        if parent.exists() {
519            parent.to_path_buf()
520        } else {
521            PathBuf::from("/")
522        }
523    } else {
524        PathBuf::from("/")
525    };
526
527    let output = Command::new("df")
528        .args(["-m", &check_path.display().to_string()])
529        .output()
530        .ok()?;
531
532    if output.status.success() {
533        let s = String::from_utf8_lossy(&output.stdout);
534        if let Some(line) = s.lines().nth(1) {
535            let parts: Vec<&str> = line.split_whitespace().collect();
536            if parts.len() >= 4 {
537                return parts[3].parse().ok();
538            }
539        }
540    }
541    None
542}
543
544/// Process status enum for the runtime check.
545#[derive(Debug)]
546enum ProcessStatus {
547    RunningResponding(i32),
548    RunningNotResponding(i32),
549    StalePid(i32),
550    NotRunning,
551    SocketConflict,
552}
553
554/// Check if the Cortex process is running and responding.
555fn check_process_status(pid_path: &PathBuf, socket_path: &str) -> ProcessStatus {
556    let pid = match std::fs::read_to_string(pid_path) {
557        Ok(s) => match s.trim().parse::<i32>() {
558            Ok(pid) => pid,
559            Err(_) => {
560                let _ = std::fs::remove_file(pid_path);
561                return if PathBuf::from(socket_path).exists() {
562                    ProcessStatus::SocketConflict
563                } else {
564                    ProcessStatus::NotRunning
565                };
566            }
567        },
568        Err(_) => {
569            return if PathBuf::from(socket_path).exists() {
570                ProcessStatus::SocketConflict
571            } else {
572                ProcessStatus::NotRunning
573            };
574        }
575    };
576
577    let alive = is_process_alive(pid);
578    if !alive {
579        return ProcessStatus::StalePid(pid);
580    }
581
582    if PathBuf::from(socket_path).exists() {
583        #[cfg(unix)]
584        {
585            match std::os::unix::net::UnixStream::connect(socket_path) {
586                Ok(_) => ProcessStatus::RunningResponding(pid),
587                Err(_) => ProcessStatus::RunningNotResponding(pid),
588            }
589        }
590        #[cfg(not(unix))]
591        {
592            ProcessStatus::RunningNotResponding(pid)
593        }
594    } else {
595        ProcessStatus::RunningNotResponding(pid)
596    }
597}
598
599/// Check if a process with the given PID is alive.
600fn is_process_alive(pid: i32) -> bool {
601    #[cfg(unix)]
602    {
603        let output = Command::new("kill").args(["-0", &pid.to_string()]).output();
604        matches!(output, Ok(o) if o.status.success())
605    }
606    #[cfg(not(unix))]
607    {
608        let _ = pid;
609        false
610    }
611}
612
613/// Check if the system uses musl libc (Alpine Linux).
614#[cfg(target_os = "linux")]
615fn is_musl_libc() -> bool {
616    // Check ldd --version output for "musl"
617    if let Ok(output) = Command::new("ldd").arg("--version").output() {
618        let stderr = String::from_utf8_lossy(&output.stderr);
619        let stdout = String::from_utf8_lossy(&output.stdout);
620        if stderr.contains("musl") || stdout.contains("musl") {
621            return true;
622        }
623    }
624    // Check if /lib/ld-musl-*.so.1 exists
625    if let Ok(entries) = std::fs::read_dir("/lib") {
626        for entry in entries.flatten() {
627            if let Some(name) = entry.file_name().to_str() {
628                if name.starts_with("ld-musl") {
629                    return true;
630                }
631            }
632        }
633    }
634    false
635}
636
637/// Check if running inside Docker.
638fn is_docker() -> bool {
639    PathBuf::from("/.dockerenv").exists()
640        || std::fs::read_to_string("/proc/1/cgroup")
641            .map(|s| s.contains("docker") || s.contains("containerd"))
642            .unwrap_or(false)
643}
644
645/// Scan cached maps directory and return (count, names, total_size_bytes).
646fn scan_cached_maps(maps_dir: &PathBuf) -> (usize, Vec<String>, u64) {
647    let mut names = Vec::new();
648    let mut total = 0u64;
649
650    if let Ok(entries) = std::fs::read_dir(maps_dir) {
651        for entry in entries.flatten() {
652            let path = entry.path();
653            if path.extension().is_some_and(|e| e == "ctx") {
654                if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
655                    names.push(stem.to_string());
656                }
657                if let Ok(meta) = path.metadata() {
658                    total += meta.len();
659                }
660            }
661        }
662    }
663
664    names.sort();
665    let count = names.len();
666    (count, names, total)
667}
668
669/// Check if a tool exists and return its version string.
670fn check_tool_version(cmd: &str, args: &[&str]) -> Option<String> {
671    let output = Command::new(cmd).args(args).output().ok()?;
672    if output.status.success() {
673        let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
674        let clean = raw
675            .replace("Python ", "")
676            .replace("python ", "")
677            .replace("v", "");
678        Some(if clean.is_empty() { raw } else { clean })
679    } else {
680        None
681    }
682}
683
684/// Suggest shared library installation commands.
685fn suggest_shared_libs() {
686    output::print_detail("Missing shared libraries for Chromium.");
687    output::print_detail(
688        "Fix (Ubuntu/Debian): sudo apt install libnss3 libatk1.0-0 libatk-bridge2.0-0",
689    );
690    output::print_detail("Fix (Alpine):        apk add nss atk at-spi2-atk");
691}
692
693/// Check shared libraries on Linux.
694#[cfg(target_os = "linux")]
695fn check_shared_libs(s: &Styled, ready: &mut bool) {
696    let libs = [
697        "libnss3",
698        "libatk1.0-0",
699        "libatk-bridge2.0-0",
700        "libcups2",
701        "libxcomposite1",
702        "libxrandr2",
703    ];
704    let mut missing = Vec::new();
705    for lib in &libs {
706        if Command::new("ldconfig")
707            .args(["-p"])
708            .output()
709            .ok()
710            .map(|o| !String::from_utf8_lossy(&o.stdout).contains(lib))
711            .unwrap_or(true)
712        {
713            missing.push(*lib);
714        }
715    }
716    if !missing.is_empty() {
717        output::print_check(
718            s.warn_sym(),
719            "Shared libs:",
720            &format!("missing: {}", missing.join(", ")),
721        );
722        output::print_detail(&format!(
723            "Fix (Ubuntu/Debian): sudo apt install {}",
724            missing.join(" ")
725        ));
726        *ready = false;
727    }
728}