Skip to main content

mvm_cli/
doctor.rs

1use anyhow::Result;
2use serde::Serialize;
3
4use crate::ui;
5use mvm_core::config::fc_version;
6use mvm_core::platform::{self, Platform};
7use mvm_runtime::shell;
8use mvm_runtime::vm::lima;
9
10#[derive(Debug, Serialize)]
11struct Check {
12    name: &'static str,
13    category: &'static str,
14    ok: bool,
15    info: String,
16}
17
18#[derive(Debug, Serialize)]
19struct DoctorReport {
20    checks: Vec<Check>,
21    all_ok: bool,
22}
23
24pub fn run(json: bool) -> Result<()> {
25    let mut checks = Vec::new();
26
27    // ── Tools ──────────────────────────────────────────────────────
28    checks.push(check_cmd("rustup", "tools", "rustup --version"));
29    checks.push(check_cmd("cargo", "tools", "cargo --version"));
30
31    let in_vm = shell::inside_lima();
32    if in_vm {
33        // Inside Lima VM: limactl is not needed, nix and firecracker are local
34        checks.push(check_cmd("nix", "tools", "nix --version"));
35        checks.push(check_cmd("firecracker", "tools", "firecracker --version"));
36    } else {
37        // On host: limactl needed for macOS, firecracker checked via Lima
38        if platform::current().needs_lima() {
39            checks.push(check_cmd("limactl", "tools", "limactl --version"));
40        }
41        checks.push(check_vm_cmd(
42            "firecracker",
43            "tools",
44            "firecracker --version",
45        ));
46    }
47
48    checks.push(Check {
49        name: "fc target",
50        category: "tools",
51        ok: true,
52        info: fc_version(),
53    });
54
55    // ── Platform ──────────────────────────────────────────────────
56    let plat = platform::current();
57    checks.push(Check {
58        name: "platform",
59        category: "platform",
60        ok: true,
61        info: platform_description(plat),
62    });
63
64    checks.push(kvm_check(plat, in_vm));
65
66    if plat.needs_lima() {
67        checks.push(lima_status_check());
68    }
69
70    checks.push(disk_space_check(in_vm));
71
72    // ── Render ────────────────────────────────────────────────────
73    let all_ok = checks.iter().all(|c| c.ok);
74    let report = DoctorReport { checks, all_ok };
75
76    if json {
77        println!("{}", serde_json::to_string_pretty(&report)?);
78        if !report.all_ok {
79            anyhow::bail!("doctor found issues");
80        }
81        return Ok(());
82    }
83
84    render_text(&report);
85
86    if !report.all_ok {
87        let missing: Vec<&Check> = report.checks.iter().filter(|c| !c.ok).collect();
88        ui::warn("\nIssues found:");
89        for m in &missing {
90            ui::info(&format!("  {} — {}", m.name, m.info));
91        }
92        anyhow::bail!("doctor found issues");
93    }
94
95    ui::success("\nAll checks passed.");
96    Ok(())
97}
98
99fn render_text(report: &DoctorReport) {
100    let mut current_category = "";
101    for c in &report.checks {
102        if c.category != current_category {
103            current_category = c.category;
104            let title = match current_category {
105                "tools" => "Tools",
106                "platform" => "Platform",
107                _ => current_category,
108            };
109            println!("\n{}", title);
110            println!("{}", "-".repeat(title.len()));
111        }
112        let status = if c.ok { "OK" } else { "MISSING" };
113        ui::status_line(
114            &format!("  {}:", c.name),
115            &format!("{} ({})", status, c.info),
116        );
117    }
118}
119
120// ── Tool checks ───────────────────────────────────────────────────────────
121
122fn check_cmd(name: &'static str, category: &'static str, cmd: &'static str) -> Check {
123    match shell::run_host("bash", &["-lc", cmd]) {
124        Ok(out) if out.status.success() => Check {
125            name,
126            category,
127            ok: true,
128            info: String::from_utf8_lossy(&out.stdout).trim().to_string(),
129        },
130        Ok(out) => Check {
131            name,
132            category,
133            ok: false,
134            info: String::from_utf8_lossy(&out.stderr).trim().to_string(),
135        },
136        Err(e) => Check {
137            name,
138            category,
139            ok: false,
140            info: e.to_string(),
141        },
142    }
143}
144
145fn check_vm_cmd(name: &'static str, category: &'static str, cmd: &'static str) -> Check {
146    match shell::run_on_vm("mvm", cmd) {
147        Ok(out) if out.status.success() => Check {
148            name,
149            category,
150            ok: true,
151            info: String::from_utf8_lossy(&out.stdout).trim().to_string(),
152        },
153        Ok(out) => Check {
154            name,
155            category,
156            ok: false,
157            info: String::from_utf8_lossy(&out.stderr).trim().to_string(),
158        },
159        Err(e) => Check {
160            name,
161            category,
162            ok: false,
163            info: e.to_string(),
164        },
165    }
166}
167
168// ── Platform checks ───────────────────────────────────────────────────────
169
170fn platform_description(plat: Platform) -> String {
171    match plat {
172        Platform::MacOS => "macOS (Lima required)".to_string(),
173        Platform::LinuxNative => "Linux with KVM".to_string(),
174        Platform::LinuxNoKvm => "Linux without KVM (Lima required)".to_string(),
175    }
176}
177
178fn kvm_check(plat: Platform, in_vm: bool) -> Check {
179    // Inside Lima VM or native Linux: check /dev/kvm locally
180    if in_vm || plat == Platform::LinuxNative || plat == Platform::LinuxNoKvm {
181        // Use test -c (character device exists) rather than test -r (readable),
182        // because KVM access may be via group membership which doesn't imply -r.
183        return match shell::run_host("bash", &["-c", "test -c /dev/kvm && echo ok"]) {
184            Ok(out) if out.status.success() => {
185                let context = if in_vm {
186                    "available (inside Lima VM)"
187                } else {
188                    "available"
189                };
190                Check {
191                    name: "kvm",
192                    category: "platform",
193                    ok: true,
194                    info: context.to_string(),
195                }
196            }
197            _ => Check {
198                name: "kvm",
199                category: "platform",
200                ok: false,
201                info: if in_vm {
202                    "/dev/kvm not accessible inside Lima VM".to_string()
203                } else {
204                    "not available. Enable virtualization in BIOS or check permissions on /dev/kvm."
205                        .to_string()
206                },
207            },
208        };
209    }
210
211    // macOS host: check /dev/kvm inside the Lima VM
212    match shell::run_in_vm("test -c /dev/kvm && echo ok") {
213        Ok(out) if out.status.success() => Check {
214            name: "kvm",
215            category: "platform",
216            ok: true,
217            info: "available (via Lima VM)".to_string(),
218        },
219        _ => Check {
220            name: "kvm",
221            category: "platform",
222            ok: false,
223            info: "Lima VM not running or /dev/kvm unavailable. Run 'mvm setup'.".to_string(),
224        },
225    }
226}
227
228fn lima_status_check() -> Check {
229    match lima::get_status() {
230        Ok(lima::LimaStatus::Running) => Check {
231            name: "lima vm",
232            category: "platform",
233            ok: true,
234            info: "running".to_string(),
235        },
236        Ok(lima::LimaStatus::Stopped) => Check {
237            name: "lima vm",
238            category: "platform",
239            ok: false,
240            info: "stopped. Run 'mvm dev' or 'limactl start mvm'.".to_string(),
241        },
242        Ok(lima::LimaStatus::NotFound) => Check {
243            name: "lima vm",
244            category: "platform",
245            ok: false,
246            info: "not found. Run 'mvm setup' or 'mvm bootstrap'.".to_string(),
247        },
248        Err(e) => Check {
249            name: "lima vm",
250            category: "platform",
251            ok: false,
252            info: format!("check failed: {}", e),
253        },
254    }
255}
256
257fn disk_space_check(in_vm: bool) -> Check {
258    let result = if in_vm {
259        parse_disk_space("df -BG ~/.mvm 2>/dev/null || df -BG / 2>/dev/null")
260    } else if cfg!(target_os = "macos") {
261        parse_disk_space("df -g ~ 2>/dev/null")
262    } else {
263        parse_disk_space("df -BG ~/.mvm 2>/dev/null || df -BG / 2>/dev/null")
264    };
265
266    match result {
267        Some(gib) if gib >= 10 => Check {
268            name: "disk space",
269            category: "platform",
270            ok: true,
271            info: format!("{} GiB free", gib),
272        },
273        Some(gib) => Check {
274            name: "disk space",
275            category: "platform",
276            ok: false,
277            info: format!("only {} GiB free (10 GiB recommended)", gib),
278        },
279        None => Check {
280            name: "disk space",
281            category: "platform",
282            ok: true,
283            info: "unable to determine (skipped)".to_string(),
284        },
285    }
286}
287
288/// Parse free disk space in GiB from `df` output.
289/// Expects the 4th column of the 2nd line to be the available space with a G suffix.
290fn parse_disk_space(cmd: &str) -> Option<u64> {
291    let output = shell::run_host("bash", &["-c", cmd]).ok()?;
292    let stdout = String::from_utf8_lossy(&output.stdout);
293    let line = stdout.lines().nth(1)?;
294    let avail = line.split_whitespace().nth(3)?;
295    let num_str = avail.trim_end_matches('G').trim_end_matches('i');
296    num_str.parse().ok()
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn check_struct_reports_ok() {
305        let c = Check {
306            name: "test-tool",
307            category: "tools",
308            ok: true,
309            info: "1.0.0".to_string(),
310        };
311        assert!(c.ok);
312        assert_eq!(c.name, "test-tool");
313    }
314
315    #[test]
316    fn check_struct_reports_missing() {
317        let c = Check {
318            name: "missing-tool",
319            category: "tools",
320            ok: false,
321            info: "not found".to_string(),
322        };
323        assert!(!c.ok);
324    }
325
326    #[test]
327    fn inside_lima_is_false_on_host() {
328        if std::env::var("LIMA_INSTANCE").is_err()
329            && !std::path::Path::new("/etc/lima-boot.conf").exists()
330        {
331            assert!(!shell::inside_lima());
332        }
333    }
334
335    #[test]
336    fn check_cmd_rustup_on_host() {
337        let c = check_cmd("rustup", "tools", "rustup --version");
338        assert!(c.ok, "rustup should be available: {}", c.info);
339        assert!(
340            c.info.contains("rustup"),
341            "expected version string, got: {}",
342            c.info
343        );
344    }
345
346    #[test]
347    fn check_cmd_cargo_on_host() {
348        let c = check_cmd("cargo", "tools", "cargo --version");
349        assert!(c.ok, "cargo should be available: {}", c.info);
350        assert!(
351            c.info.contains("cargo"),
352            "expected version string, got: {}",
353            c.info
354        );
355    }
356
357    #[test]
358    fn check_cmd_missing_tool() {
359        let c = check_cmd(
360            "nonexistent-mvm-tool-xyz",
361            "tools",
362            "nonexistent-mvm-tool-xyz --version",
363        );
364        assert!(!c.ok, "nonexistent tool should fail");
365    }
366
367    #[test]
368    fn fc_target_version_is_nonempty() {
369        let v = mvm_core::config::fc_version();
370        assert!(!v.is_empty(), "FC version should be configured");
371        assert!(
372            v.starts_with('v'),
373            "FC version should start with 'v': {}",
374            v
375        );
376    }
377
378    #[test]
379    fn platform_description_covers_all_variants() {
380        assert!(platform_description(Platform::MacOS).contains("macOS"));
381        assert!(platform_description(Platform::LinuxNative).contains("KVM"));
382        assert!(platform_description(Platform::LinuxNoKvm).contains("without KVM"));
383    }
384
385    #[test]
386    fn parse_disk_space_typical_output() {
387        let result = parse_disk_space(
388            "printf 'Filesystem     1G-blocks  Used Available Use%% Mounted on\n/dev/sda1           100G   55G       45G  55%% /\n'",
389        );
390        assert_eq!(result, Some(45));
391    }
392
393    #[test]
394    fn doctor_report_serializes_to_json() {
395        let report = DoctorReport {
396            checks: vec![Check {
397                name: "test",
398                category: "tools",
399                ok: true,
400                info: "v1.0".to_string(),
401            }],
402            all_ok: true,
403        };
404        let json = serde_json::to_string(&report).unwrap();
405        assert!(json.contains("\"name\":\"test\""));
406        assert!(json.contains("\"all_ok\":true"));
407    }
408}