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", "prerequisites", "rustup --version"));
29 checks.push(check_cmd("cargo", "prerequisites", "cargo --version"));
30
31 let in_vm = shell::inside_lima();
34 if in_vm {
35 checks.push(nix_version_check(None));
37 checks.push(check_cmd("firecracker", "tools", "firecracker --version"));
38 } else {
39 if platform::current().needs_lima() {
41 checks.push(check_cmd("limactl", "tools", "limactl --version"));
42 }
43 checks.push(nix_version_check(Some("mvm")));
44 checks.push(check_vm_cmd(
45 "firecracker",
46 "tools",
47 "firecracker --version",
48 ));
49 }
50
51 checks.push(Check {
52 name: "fc target",
53 category: "tools",
54 ok: true,
55 info: fc_version(),
56 });
57
58 checks.push(nix_flakes_check(in_vm));
60
61 let plat = platform::current();
63 checks.push(Check {
64 name: "platform",
65 category: "platform",
66 ok: true,
67 info: platform_description(plat),
68 });
69
70 checks.push(kvm_check(plat, in_vm));
71 checks.push(apple_container_check(plat));
72 checks.push(docker_check(plat));
73
74 if plat.needs_lima() {
75 checks.push(lima_status_check());
76 }
77
78 checks.push(disk_space_check(in_vm));
79
80 if plat.needs_lima() {
82 checks.push(lima_disk_check());
83 }
84
85 checks.push(nix_store_check(in_vm));
87 checks.push(nix_store_size_check(in_vm));
88
89 let all_ok = checks.iter().all(|c| c.ok);
91 let report = DoctorReport { checks, all_ok };
92
93 if json {
94 println!("{}", serde_json::to_string_pretty(&report)?);
95 if !report.all_ok {
96 anyhow::bail!("doctor found issues");
97 }
98 return Ok(());
99 }
100
101 render_text(&report);
102
103 if !report.all_ok {
104 let missing: Vec<&Check> = report.checks.iter().filter(|c| !c.ok).collect();
105 ui::warn("\nIssues found:");
106 for m in &missing {
107 ui::info(&format!(" {} — {}", m.name, m.info));
108 }
109
110 let has_prerequisites = missing.iter().any(|c| c.category == "prerequisites");
112 let has_managed = missing.iter().any(|c| c.category == "tools");
113
114 if has_prerequisites {
115 ui::info("\nPrerequisites missing: Install Rust from https://rustup.rs");
116 }
117 if has_managed {
118 ui::info("\nManaged tools missing: Run 'mvmctl bootstrap' to install");
119 }
120
121 anyhow::bail!("doctor found issues");
122 }
123
124 ui::success("\nAll checks passed.");
125 Ok(())
126}
127
128fn render_text(report: &DoctorReport) {
129 let mut current_category = "";
130 for c in &report.checks {
131 if c.category != current_category {
132 current_category = c.category;
133 let title = match current_category {
134 "prerequisites" => "Prerequisites",
135 "tools" => "Tools",
136 "platform" => "Platform",
137 _ => current_category,
138 };
139 println!("\n{}", title);
140 println!("{}", "-".repeat(title.len()));
141 }
142 let status = if c.ok { "OK" } else { "MISSING" };
143 ui::status_line(
144 &format!(" {}:", c.name),
145 &format!("{} ({})", status, c.info),
146 );
147 }
148}
149
150fn check_cmd(name: &'static str, category: &'static str, cmd: &'static str) -> Check {
153 match shell::run_host("bash", &["-lc", cmd]) {
154 Ok(out) if out.status.success() => Check {
155 name,
156 category,
157 ok: true,
158 info: String::from_utf8_lossy(&out.stdout).trim().to_string(),
159 },
160 Ok(out) => Check {
161 name,
162 category,
163 ok: false,
164 info: String::from_utf8_lossy(&out.stderr).trim().to_string(),
165 },
166 Err(e) => Check {
167 name,
168 category,
169 ok: false,
170 info: e.to_string(),
171 },
172 }
173}
174
175fn check_vm_cmd(name: &'static str, category: &'static str, cmd: &'static str) -> Check {
176 match shell::run_on_vm("mvm", cmd) {
177 Ok(out) if out.status.success() => Check {
178 name,
179 category,
180 ok: true,
181 info: String::from_utf8_lossy(&out.stdout).trim().to_string(),
182 },
183 Ok(out) => Check {
184 name,
185 category,
186 ok: false,
187 info: String::from_utf8_lossy(&out.stderr).trim().to_string(),
188 },
189 Err(e) => Check {
190 name,
191 category,
192 ok: false,
193 info: e.to_string(),
194 },
195 }
196}
197
198fn platform_description(plat: Platform) -> String {
201 match plat {
202 Platform::MacOS => "macOS".to_string(),
203 Platform::LinuxNative => "Linux with KVM".to_string(),
204 Platform::LinuxNoKvm => "Linux without KVM".to_string(),
205 Platform::Wsl2 => {
206 if plat.has_kvm() {
207 "WSL2 (KVM available)".to_string()
208 } else {
209 "WSL2 (no KVM)".to_string()
210 }
211 }
212 Platform::Windows => "Windows".to_string(),
213 }
214}
215
216fn kvm_check(plat: Platform, in_vm: bool) -> Check {
217 if in_vm
219 || plat == Platform::LinuxNative
220 || plat == Platform::LinuxNoKvm
221 || plat == Platform::Wsl2
222 {
223 return match shell::run_host("bash", &["-c", "test -c /dev/kvm && echo ok"]) {
226 Ok(out) if out.status.success() => {
227 let context = if in_vm {
228 "available (inside Lima VM)"
229 } else {
230 "available"
231 };
232 Check {
233 name: "kvm",
234 category: "platform",
235 ok: true,
236 info: context.to_string(),
237 }
238 }
239 _ => Check {
240 name: "kvm",
241 category: "platform",
242 ok: false,
243 info: if in_vm {
244 "/dev/kvm not accessible inside Lima VM".to_string()
245 } else {
246 "not available. Enable virtualization in BIOS or check permissions on /dev/kvm."
247 .to_string()
248 },
249 },
250 };
251 }
252
253 match shell::run_in_vm("test -c /dev/kvm && echo ok") {
255 Ok(out) if out.status.success() => Check {
256 name: "kvm",
257 category: "platform",
258 ok: true,
259 info: "available (via Lima VM)".to_string(),
260 },
261 _ => Check {
262 name: "kvm",
263 category: "platform",
264 ok: false,
265 info: "Lima VM not running or /dev/kvm unavailable. Run 'mvmctl setup'.".to_string(),
266 },
267 }
268}
269
270fn apple_container_check(plat: Platform) -> Check {
271 if plat != Platform::MacOS {
272 return Check {
273 name: "apple containers",
274 category: "platform",
275 ok: true,
276 info: "n/a (not macOS)".to_string(),
277 };
278 }
279
280 if plat.has_apple_containers() {
281 Check {
282 name: "apple containers",
283 category: "platform",
284 ok: true,
285 info: "available (macOS 26+ on Apple Silicon)".to_string(),
286 }
287 } else {
288 Check {
289 name: "apple containers",
290 category: "platform",
291 ok: true, info: "not available (requires macOS 26+ on Apple Silicon)".to_string(),
293 }
294 }
295}
296
297fn docker_check(plat: Platform) -> Check {
298 if plat.has_docker() {
299 Check {
300 name: "docker",
301 category: "platform",
302 ok: true,
303 info: "available".to_string(),
304 }
305 } else {
306 Check {
307 name: "docker",
308 category: "platform",
309 ok: true, info: "not available (install Docker Desktop or Docker Engine)".to_string(),
311 }
312 }
313}
314
315fn lima_status_check() -> Check {
316 match lima::get_status() {
317 Ok(lima::LimaStatus::Running) => Check {
318 name: "lima vm",
319 category: "platform",
320 ok: true,
321 info: "running".to_string(),
322 },
323 Ok(lima::LimaStatus::Stopped) => Check {
324 name: "lima vm",
325 category: "platform",
326 ok: false,
327 info: "stopped. Run 'mvmctl dev' or 'limactl start mvm'.".to_string(),
328 },
329 Ok(lima::LimaStatus::NotFound) => Check {
330 name: "lima vm",
331 category: "platform",
332 ok: false,
333 info: "not found. Run 'mvmctl setup' or 'mvmctl bootstrap'.".to_string(),
334 },
335 Err(e) => Check {
336 name: "lima vm",
337 category: "platform",
338 ok: false,
339 info: format!("check failed: {}", e),
340 },
341 }
342}
343
344fn disk_space_check(in_vm: bool) -> Check {
345 let result = if in_vm {
346 parse_disk_space("df -BG ~/.mvm 2>/dev/null || df -BG / 2>/dev/null")
347 } else if cfg!(target_os = "macos") {
348 parse_disk_space("df -g ~ 2>/dev/null")
349 } else {
350 parse_disk_space("df -BG ~/.mvm 2>/dev/null || df -BG / 2>/dev/null")
351 };
352
353 match result {
354 Some(gib) if gib >= 10 => Check {
355 name: "disk space",
356 category: "platform",
357 ok: true,
358 info: format!("{} GiB free", gib),
359 },
360 Some(gib) => Check {
361 name: "disk space",
362 category: "platform",
363 ok: false,
364 info: format!("only {} GiB free (10 GiB recommended)", gib),
365 },
366 None => Check {
367 name: "disk space",
368 category: "platform",
369 ok: true,
370 info: "unable to determine (skipped)".to_string(),
371 },
372 }
373}
374
375fn parse_disk_space(cmd: &str) -> Option<u64> {
378 let output = shell::run_host("bash", &["-c", cmd]).ok()?;
379 let stdout = String::from_utf8_lossy(&output.stdout);
380 let line = stdout.lines().nth(1)?;
381 let avail = line.split_whitespace().nth(3)?;
382 let num_str = avail.trim_end_matches('G').trim_end_matches('i');
383 num_str.parse().ok()
384}
385
386const NIX_MIN_VERSION: (u64, u64) = (2, 4);
390const NIX_RECOMMENDED_VERSION: (u64, u64) = (2, 13);
392
393fn nix_version_check(vm_name: Option<&str>) -> Check {
396 let output_result = match vm_name {
397 Some(vm) => shell::run_on_vm(vm, "nix --version"),
398 None => shell::run_host("bash", &["-lc", "nix --version"]),
399 };
400
401 match output_result {
402 Ok(out) if out.status.success() => {
403 let version_str = String::from_utf8_lossy(&out.stdout).trim().to_string();
404 match parse_nix_version(&version_str) {
405 Some((major, minor, patch)) => {
406 if (major, minor) < NIX_MIN_VERSION {
407 Check {
408 name: "nix",
409 category: "tools",
410 ok: false,
411 info: format!(
412 "{}.{}.{} (requires >= {}.{}+ for flakes)",
413 major, minor, patch, NIX_MIN_VERSION.0, NIX_MIN_VERSION.1
414 ),
415 }
416 } else if (major, minor) < NIX_RECOMMENDED_VERSION {
417 Check {
418 name: "nix",
419 category: "tools",
420 ok: true,
421 info: format!(
422 "{}.{}.{} (OK, but >= {}.{} recommended)",
423 major,
424 minor,
425 patch,
426 NIX_RECOMMENDED_VERSION.0,
427 NIX_RECOMMENDED_VERSION.1
428 ),
429 }
430 } else {
431 Check {
432 name: "nix",
433 category: "tools",
434 ok: true,
435 info: format!("{}.{}.{}", major, minor, patch),
436 }
437 }
438 }
439 None => Check {
440 name: "nix",
441 category: "tools",
442 ok: true,
443 info: format!("{} (version not parsed)", version_str),
444 },
445 }
446 }
447 Ok(out) => Check {
448 name: "nix",
449 category: "tools",
450 ok: false,
451 info: String::from_utf8_lossy(&out.stderr).trim().to_string(),
452 },
453 Err(e) => Check {
454 name: "nix",
455 category: "tools",
456 ok: false,
457 info: e.to_string(),
458 },
459 }
460}
461
462fn parse_nix_version(output: &str) -> Option<(u64, u64, u64)> {
464 let version_part = output
466 .split_whitespace()
467 .find(|s| s.chars().next().is_some_and(|c| c.is_ascii_digit()))?;
468
469 let mut parts = version_part.split('.');
470 let major = parts.next()?.parse().ok()?;
471 let minor = parts.next()?.parse().ok()?;
472 let patch_str = parts.next().unwrap_or("0");
474 let patch = patch_str
475 .chars()
476 .take_while(|c| c.is_ascii_digit())
477 .collect::<String>()
478 .parse()
479 .unwrap_or(0);
480 Some((major, minor, patch))
481}
482
483fn nix_flakes_check(in_vm: bool) -> Check {
485 let cmd = "nix show-config 2>/dev/null | grep -i experimental-features || echo 'not found'";
486 let output_result = if in_vm {
487 shell::run_host("bash", &["-lc", cmd])
488 } else {
489 shell::run_on_vm("mvm", cmd)
490 };
491
492 match output_result {
493 Ok(out) if out.status.success() => {
494 let stdout = String::from_utf8_lossy(&out.stdout);
495 let has_flakes = stdout.contains("flakes");
496 let has_nix_command = stdout.contains("nix-command");
497 if has_flakes && has_nix_command {
498 Check {
499 name: "nix flakes",
500 category: "tools",
501 ok: true,
502 info: "enabled".to_string(),
503 }
504 } else {
505 let mut missing = Vec::new();
506 if !has_nix_command {
507 missing.push("nix-command");
508 }
509 if !has_flakes {
510 missing.push("flakes");
511 }
512 Check {
513 name: "nix flakes",
514 category: "tools",
515 ok: false,
516 info: format!(
517 "missing experimental-features: {}. Add to ~/.config/nix/nix.conf",
518 missing.join(", ")
519 ),
520 }
521 }
522 }
523 _ => Check {
524 name: "nix flakes",
525 category: "tools",
526 ok: true,
527 info: "unable to check (skipped)".to_string(),
528 },
529 }
530}
531
532fn lima_disk_check() -> Check {
536 match shell::run_on_vm("mvm", "df -h / 2>/dev/null") {
537 Ok(out) if out.status.success() => {
538 let stdout = String::from_utf8_lossy(&out.stdout);
539 if let Some(pct) = stdout
541 .lines()
542 .nth(1)
543 .and_then(|line| line.split_whitespace().nth(4))
544 .and_then(|s| s.trim_end_matches('%').parse::<u64>().ok())
545 {
546 return if pct >= 90 {
547 Check {
548 name: "lima disk",
549 category: "platform",
550 ok: false,
551 info: format!("{}% used (critically low space)", pct),
552 }
553 } else if pct >= 80 {
554 Check {
555 name: "lima disk",
556 category: "platform",
557 ok: true,
558 info: format!("{}% used (consider freeing space)", pct),
559 }
560 } else {
561 Check {
562 name: "lima disk",
563 category: "platform",
564 ok: true,
565 info: format!("{}% used", pct),
566 }
567 };
568 }
569 Check {
570 name: "lima disk",
571 category: "platform",
572 ok: true,
573 info: "unable to parse (skipped)".to_string(),
574 }
575 }
576 _ => Check {
577 name: "lima disk",
578 category: "platform",
579 ok: true,
580 info: "VM not accessible (skipped)".to_string(),
581 },
582 }
583}
584
585fn nix_store_check(in_vm: bool) -> Check {
589 let cmd = "nix store ping 2>&1";
590 let output_result = if in_vm {
591 shell::run_host("bash", &["-lc", cmd])
592 } else {
593 shell::run_on_vm("mvm", cmd)
594 };
595
596 match output_result {
597 Ok(out) if out.status.success() => {
598 let stdout = String::from_utf8_lossy(&out.stdout);
599 let store_url = stdout
601 .lines()
602 .find(|l| l.contains("Store URL"))
603 .map(|l| l.trim().to_string())
604 .unwrap_or_else(|| "accessible".to_string());
605 Check {
606 name: "nix store",
607 category: "tools",
608 ok: true,
609 info: store_url,
610 }
611 }
612 Ok(_) => Check {
613 name: "nix store",
614 category: "tools",
615 ok: false,
616 info: "Nix store not accessible. Is the Nix daemon running?".to_string(),
617 },
618 _ => Check {
619 name: "nix store",
620 category: "tools",
621 ok: true,
622 info: "unable to check (skipped)".to_string(),
623 },
624 }
625}
626
627fn nix_store_size_check(in_vm: bool) -> Check {
629 let cmd = "du -sb /nix/store 2>/dev/null | awk '{print $1}'";
630 let output_result = if in_vm {
631 shell::run_host("bash", &["-lc", cmd])
632 } else {
633 shell::run_on_vm("mvm", cmd)
634 };
635
636 match output_result {
637 Ok(out) if out.status.success() => {
638 let stdout = String::from_utf8_lossy(&out.stdout);
639 let bytes: u64 = stdout.trim().parse().unwrap_or(0);
640 let threshold: u64 = 20 * 1024 * 1024 * 1024; let human = mvm_core::pool::format_bytes(bytes);
642 if bytes > threshold {
643 Check {
644 name: "nix store size",
645 category: "disk",
646 ok: false,
647 info: format!(
648 "{} — exceeds 20 GiB. Run 'nix-collect-garbage -d' to reclaim space.",
649 human
650 ),
651 }
652 } else {
653 Check {
654 name: "nix store size",
655 category: "disk",
656 ok: true,
657 info: human,
658 }
659 }
660 }
661 _ => Check {
662 name: "nix store size",
663 category: "disk",
664 ok: true,
665 info: "unable to check (skipped)".to_string(),
666 },
667 }
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673
674 #[test]
675 fn check_struct_reports_ok() {
676 let c = Check {
677 name: "test-tool",
678 category: "tools",
679 ok: true,
680 info: "1.0.0".to_string(),
681 };
682 assert!(c.ok);
683 assert_eq!(c.name, "test-tool");
684 }
685
686 #[test]
687 fn check_struct_reports_missing() {
688 let c = Check {
689 name: "missing-tool",
690 category: "tools",
691 ok: false,
692 info: "not found".to_string(),
693 };
694 assert!(!c.ok);
695 }
696
697 #[test]
698 fn inside_lima_is_false_on_host() {
699 if std::env::var("LIMA_INSTANCE").is_err()
700 && !std::path::Path::new("/etc/lima-boot.conf").exists()
701 {
702 assert!(!shell::inside_lima());
703 }
704 }
705
706 #[test]
707 fn check_cmd_rustup_on_host() {
708 let c = check_cmd("rustup", "tools", "rustup --version");
709 assert!(c.ok, "rustup should be available: {}", c.info);
710 assert!(
711 c.info.contains("rustup"),
712 "expected version string, got: {}",
713 c.info
714 );
715 }
716
717 #[test]
718 fn check_cmd_cargo_on_host() {
719 let c = check_cmd("cargo", "tools", "cargo --version");
720 assert!(c.ok, "cargo should be available: {}", c.info);
721 assert!(
722 c.info.contains("cargo"),
723 "expected version string, got: {}",
724 c.info
725 );
726 }
727
728 #[test]
729 fn check_cmd_missing_tool() {
730 let c = check_cmd(
731 "nonexistent-mvm-tool-xyz",
732 "tools",
733 "nonexistent-mvm-tool-xyz --version",
734 );
735 assert!(!c.ok, "nonexistent tool should fail");
736 }
737
738 #[test]
739 fn fc_target_version_is_nonempty() {
740 let v = mvm_core::config::fc_version();
741 assert!(!v.is_empty(), "FC version should be configured");
742 assert!(
743 v.starts_with('v'),
744 "FC version should start with 'v': {}",
745 v
746 );
747 }
748
749 #[test]
750 fn platform_description_covers_all_variants() {
751 assert!(platform_description(Platform::MacOS).contains("macOS"));
752 assert!(platform_description(Platform::LinuxNative).contains("KVM"));
753 assert!(platform_description(Platform::LinuxNoKvm).contains("without KVM"));
754 }
755
756 #[test]
757 fn parse_disk_space_typical_output() {
758 let result = parse_disk_space(
759 "printf 'Filesystem 1G-blocks Used Available Use%% Mounted on\n/dev/sda1 100G 55G 45G 55%% /\n'",
760 );
761 assert_eq!(result, Some(45));
762 }
763
764 #[test]
765 fn parse_nix_version_standard() {
766 assert_eq!(parse_nix_version("nix (Nix) 2.18.1"), Some((2, 18, 1)));
767 }
768
769 #[test]
770 fn parse_nix_version_with_suffix() {
771 assert_eq!(
772 parse_nix_version("nix (Nix) 2.24.12pre-20241211_dirty"),
773 Some((2, 24, 12))
774 );
775 }
776
777 #[test]
778 fn parse_nix_version_old() {
779 assert_eq!(parse_nix_version("nix (Nix) 2.3.16"), Some((2, 3, 16)));
780 }
781
782 #[test]
783 fn parse_nix_version_garbage() {
784 assert_eq!(parse_nix_version("not a version"), None);
785 }
786
787 #[test]
788 fn parse_nix_version_empty() {
789 assert_eq!(parse_nix_version(""), None);
790 }
791
792 #[test]
793 fn nix_version_too_old_is_not_ok() {
794 let (major, minor, _patch) = (2, 3, 16);
796 assert!((major, minor) < NIX_MIN_VERSION);
797 assert!(
799 (major, minor) < NIX_MIN_VERSION,
800 "2.3 should be below minimum"
801 );
802 }
803
804 #[test]
805 fn nix_version_at_minimum_is_ok() {
806 let (major, minor) = (2, 4);
807 assert!((major, minor) >= NIX_MIN_VERSION);
808 }
809
810 #[test]
811 fn nix_version_at_recommended_is_ok() {
812 let (major, minor) = (2, 13);
813 assert!((major, minor) >= NIX_RECOMMENDED_VERSION);
814 }
815
816 #[test]
817 fn doctor_report_serializes_to_json() {
818 let report = DoctorReport {
819 checks: vec![Check {
820 name: "test",
821 category: "tools",
822 ok: true,
823 info: "v1.0".to_string(),
824 }],
825 all_ok: true,
826 };
827 let json = serde_json::to_string(&report).unwrap();
828 assert!(json.contains("\"name\":\"test\""));
829 assert!(json.contains("\"all_ok\":true"));
830 }
831}