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 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 checks.push(check_cmd("nix", "tools", "nix --version"));
35 checks.push(check_cmd("firecracker", "tools", "firecracker --version"));
36 } else {
37 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 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 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
120fn 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
168fn 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 if in_vm || plat == Platform::LinuxNative || plat == Platform::LinuxNoKvm {
181 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 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
288fn 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}