1use 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
12pub 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 output::print_header(&s);
24
25 output::print_section(&s, "System");
27
28 let os = format_os();
30 let arch = std::env::consts::ARCH;
31 output::print_check(s.ok_sym(), "OS:", &format!("{os} ({arch})"));
32
33 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 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 output::print_section(&s, "Browser");
95
96 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 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 #[cfg(target_os = "linux")]
140 check_shared_libs(&s, &mut ready);
141
142 #[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 output::print_section(&s, "Runtime");
160
161 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 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 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 output::print_section(&s, "Cache");
222
223 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 output::print_section(&s, "Optional");
254
255 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 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 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
293async 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
325fn 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
353pub 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
363pub fn find_chromium() -> Option<PathBuf> {
365 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 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 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 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
416fn 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
427fn 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
452fn 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
513fn 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#[derive(Debug)]
546enum ProcessStatus {
547 RunningResponding(i32),
548 RunningNotResponding(i32),
549 StalePid(i32),
550 NotRunning,
551 SocketConflict,
552}
553
554fn 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
599fn 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#[cfg(target_os = "linux")]
615fn is_musl_libc() -> bool {
616 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 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
637fn 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
645fn 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
669fn 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
684fn 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#[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}