Skip to main content

browser_automation_cli/doctor/
mod.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Local diagnostics for one-shot installs (no multi-process daemon).
3//!
4//! # Workload
5//!
6//! **I/O-light sequential (N-144 / PAR-57 / PAR-73 honesty):** each check is an
7//! independent path/binary probe assembled in stable report order. Probes are
8//! cheap (stat/which); Rayon would rarely beat sequential assembly and the
9//! matrix **must not** claim `map_cpu` for doctor. Concurrency budget is still
10//! exported for agents (`budget_report` / `by_command.doctor`).
11
12use serde_json::json;
13
14use crate::envelope::print_success_json;
15use crate::install;
16use crate::native::cdp::chrome;
17
18/// Options for local install diagnostics.
19#[derive(Default, Clone, Copy)]
20pub struct DoctorOptions {
21    /// Skip network checks.
22    pub offline: bool,
23    /// Run a reduced check set.
24    pub quick: bool,
25    /// Attempt automatic remediations when supported.
26    pub fix: bool,
27    /// Emit JSON envelope on stdout.
28    pub json: bool,
29}
30
31/// Run doctor checks and return a process exit code (`0` = all pass).
32pub fn run_doctor(opts: DoctorOptions) -> i32 {
33    let mut checks = Vec::new();
34    let mut failed = false;
35
36    let host = crate::platform::HostEnvironment::detect();
37    checks.push(json!({
38        "id": "host_environment",
39        "status": "info",
40        "message": host.summary(),
41        "wsl": host.wsl,
42        "container": host.container,
43        "ci": host.ci,
44        "termux": host.termux,
45        "flatpak": host.flatpak,
46        "snap": host.snap,
47        "os": std::env::consts::OS,
48        "arch": std::env::consts::ARCH,
49    }));
50
51    let chrome = chrome::find_chrome();
52    match &chrome {
53        Some(p) => {
54            let sandbox = crate::platform::detect_browser_sandbox(p);
55            let executable = crate::platform::is_executable_file(p);
56            let mut status = "pass";
57            let mut message = format!("found {}", p.display());
58            if sandbox.is_restricted() {
59                status = "warn";
60                message = format!(
61                    "found {} (sandbox={}; CDP may fail — prefer system package or config set chrome_path)",
62                    p.display(),
63                    sandbox.as_str()
64                );
65            } else if !executable {
66                status = "fail";
67                failed = true;
68                message = format!("found {} but not executable", p.display());
69            }
70            checks.push(json!({
71                "id": "chrome",
72                "status": status,
73                "message": message,
74                "path": p.display().to_string(),
75                "sandbox": sandbox.as_str(),
76                "executable": executable,
77            }));
78        }
79        None => {
80            failed = true;
81            checks.push(json!({
82                "id":"chrome",
83                "status":"fail",
84                "message":"Chrome/Chromium not found on PATH, known install paths, or product cache",
85                "fix":"install Chromium/Chrome or: browser-automation-cli config set chrome_path /path/to/chrome",
86                "sandbox": "none",
87                "executable": false,
88            }));
89        }
90    }
91
92    let cache = install::get_browsers_dir();
93    checks.push(json!({
94        "id":"browsers_dir",
95        "status":"info",
96        "message": format!("cache dir {}", cache.display())
97    }));
98
99    if !opts.quick && chrome.is_some() {
100        match crate::browser::block_on_browser(async {
101            let mut s = crate::browser::OneShotSession::launch_headless().await?;
102            let _ = s
103                .goto("about:blank", crate::robots::RobotsPolicy::Ignore)
104                .await?;
105            let _ = s.shutdown().await;
106            Ok::<_, crate::error::CliError>(())
107        }) {
108            Ok(()) => checks
109                .push(json!({"id":"launch","status":"pass","message":"headless about:blank ok"})),
110            Err(e) => {
111                failed = true;
112                checks.push(json!({"id":"launch","status":"fail","message": e.message()}));
113            }
114        }
115    }
116
117    // Lighthouse: external binary only (PATH or XDG); never npm (GAP-A010 / LH-2).
118    let lh_resolved = resolve_lighthouse_for_doctor();
119    let lighthouse_present = lh_resolved.is_some();
120    match &lh_resolved {
121        Some((path, source)) => checks.push(json!({
122            "id": "lighthouse",
123            "status": "pass",
124            "message": format!("found {path} (source={source})"),
125            "lighthouse_present": true,
126            "lighthouse_resolved": path,
127            "lighthouse_source": source,
128        })),
129        None => {
130            let mut fix_msg = None;
131            if opts.fix {
132                fix_msg = Some(
133                    "browser-automation-cli config set lighthouse_path /absolute/path/to/lighthouse",
134                );
135            }
136            let xdg_path = crate::xdg::lighthouse_path_from_config().filter(|s| !s.is_empty());
137            let mut entry = json!({
138                "id": "lighthouse",
139                "status": "info",
140                "message": "lighthouse not on PATH or XDG (optional external binary; e2e may use mock-lighthouse.sh)",
141                "lighthouse_present": false,
142                "lighthouse_resolved": null,
143                "lighthouse_source": "missing",
144                "lighthouse_path_xdg": xdg_path,
145                "suggestion": "browser-automation-cli config set lighthouse_path /absolute/path/to/lighthouse",
146            });
147            if let Some(fix) = fix_msg {
148                entry
149                    .as_object_mut()
150                    .unwrap()
151                    .insert("fix".into(), json!(fix));
152            }
153            checks.push(entry);
154        }
155    }
156
157    // Cache backend health (R-LIVE-3): PING only when XDG cache_backend=redis.
158    checks.push(cache_redis_check());
159
160    let ffmpeg_present = which_bin("ffmpeg");
161    checks.push(json!({
162        "id": "ffmpeg",
163        "status": if ffmpeg_present.is_some() { "pass" } else { "info" },
164        "message": ffmpeg_present
165            .as_deref()
166            .map(|p| format!("found {p}"))
167            .unwrap_or_else(|| "ffmpeg not on PATH (optional for media pipelines)".into()),
168        "ffmpeg_present": ffmpeg_present.is_some(),
169    }));
170
171    // GAP-009: report Job Object capability (real on Windows, stub elsewhere).
172    checks.push(json!({
173        "id": "windows_job_object",
174        "status": "info",
175        "message": crate::win_job::capability_summary(),
176        "supported": crate::win_job::platform_supports_job_objects(),
177    }));
178
179    // Wire offline: skip any future network probes; record mode for agents.
180    if opts.offline {
181        checks.push(json!({
182            "id": "offline",
183            "status": "pass",
184            "message": "offline mode: network probes skipped",
185            "offline": true,
186        }));
187    }
188
189    // Wire fix: when lighthouse missing and --fix, config path is the remediation
190    // (already attached on the lighthouse check). When chrome missing, restate install.
191    if opts.fix && chrome.is_none() {
192        if let Some(c) = checks
193            .iter_mut()
194            .find(|c| c.get("id").and_then(|v| v.as_str()) == Some("chrome"))
195        {
196            if let Some(obj) = c.as_object_mut() {
197                obj.insert(
198                    "fix".into(),
199                    json!("install system Chrome/Chromium or: browser-automation-cli config set chrome_path /path/to/chrome"),
200                );
201            }
202        }
203    }
204
205    // Parallelism budget (rules_rust_paralelismo) — agent-visible, host-local only.
206    checks.push(json!({
207        "id": "concurrency_budget",
208        "status": "info",
209        "message": "bounded fan-out budget (CPU × free RAM × 50% / 64MiB, hard cap 64)",
210        "budget": crate::concurrency::budget_report(),
211    }));
212
213    // RES-04: residual disk hygiene (path-light; no Chrome launch).
214    // Lifecycle::new already ran BORN stale GC; report reflects post-BORN state
215    // when doctor is invoked through the normal CLI entry (lib::run).
216    let residual = crate::residual::residual_disk_report();
217    let residual_status = if residual.live_cli_marker_processes > 0 {
218        failed = true;
219        "fail"
220    } else if residual.cli_marker_dirs > 0 || residual.chromium_tmp_singleton_orphans > 0 {
221        "warn"
222    } else {
223        "pass"
224    };
225    checks.push(json!({
226        "id": "residual_disk",
227        "status": residual_status,
228        "message": format!(
229            "cli_markers={} chromium_singleton_orphans={} scavenge_safe={} live_cli_marker_procs={}",
230            residual.cli_marker_dirs,
231            residual.chromium_tmp_singleton_orphans,
232            residual.scavenge_safe_candidates,
233            residual.live_cli_marker_processes
234        ),
235        "cli_marker_dirs": residual.cli_marker_dirs,
236        "chromium_tmp_singleton_orphans": residual.chromium_tmp_singleton_orphans,
237        "scavenge_safe_candidates": residual.scavenge_safe_candidates,
238        "live_cli_marker_processes": residual.live_cli_marker_processes,
239    }));
240
241    let data = json!({
242        "schema_version": 1,
243        "checks": checks,
244        "lighthouse_present": lighthouse_present,
245        "ffmpeg_present": ffmpeg_present.is_some(),
246        "offline": opts.offline,
247        "fix_requested": opts.fix,
248        "ok": !failed,
249        "concurrency": crate::concurrency::budget_report(),
250        "residual": residual,
251    });
252
253    if opts.json {
254        match print_success_json(data) {
255            Ok(()) => {}
256            Err(e) if e.kind() == crate::error::ErrorKind::BrokenPipe => return 141,
257            Err(_) => {}
258        }
259    } else {
260        for c in checks {
261            let line = format!(
262                "[{}] {} — {}",
263                c.get("status").and_then(|s| s.as_str()).unwrap_or("?"),
264                c.get("id").and_then(|s| s.as_str()).unwrap_or("?"),
265                c.get("message").and_then(|s| s.as_str()).unwrap_or("")
266            );
267            match crate::output::writeln_stdout(line) {
268                Ok(()) => {}
269                Err(e) if e.kind() == crate::error::ErrorKind::BrokenPipe => return 141,
270                Err(_) => {}
271            }
272        }
273        let _ = crate::output::flush_stdout();
274    }
275
276    if failed {
277        1
278    } else {
279        0
280    }
281}
282
283/// Resolve lighthouse for doctor: XDG path if executable, else PATH.
284fn resolve_lighthouse_for_doctor() -> Option<(String, &'static str)> {
285    if let Some(xdg) = crate::xdg::lighthouse_path_from_config().filter(|s| !s.is_empty()) {
286        let p = std::path::Path::new(&xdg);
287        if p.is_file() {
288            let source = if xdg.contains("mock-lighthouse") {
289                "mock"
290            } else {
291                "xdg"
292            };
293            return Some((xdg, source));
294        }
295    }
296    which_bin("lighthouse").map(|p| (p, "path"))
297}
298
299/// Report redis cache health from XDG only (no product env).
300fn cache_redis_check() -> serde_json::Value {
301    let cfg = crate::xdg::load_config().unwrap_or_default();
302    let backend = cfg
303        .cache_backend
304        .as_deref()
305        .unwrap_or("sqlite")
306        .to_ascii_lowercase();
307    if backend != "redis" {
308        return json!({
309            "id": "cache_redis",
310            "status": "info",
311            "backend": backend,
312            "message": format!("redis not active (cache_backend={backend})"),
313        });
314    }
315    let url = cfg.cache_redis_url.as_deref().unwrap_or("");
316    match crate::cache::RedisCache::connect(url) {
317        Ok(_) => json!({
318            "id": "cache_redis",
319            "status": "pass",
320            "backend": "redis",
321            "message": "redis PING ok (XDG cache_redis_url)",
322        }),
323        Err(e) => json!({
324            "id": "cache_redis",
325            "status": "fail",
326            "backend": "redis",
327            "message": e.message(),
328            "suggestion": "Start redis-server or: browser-automation-cli config set cache_backend sqlite",
329        }),
330    }
331}
332
333fn which_bin(name: &str) -> Option<String> {
334    crate::platform::which_bin(name).map(|p| p.display().to_string())
335}