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    // ── Prerequisites (user must install before bootstrap) ───────
28    checks.push(check_cmd("rustup", "prerequisites", "rustup --version"));
29    checks.push(check_cmd("cargo", "prerequisites", "cargo --version"));
30
31    // ── Managed Tools (installed by bootstrap) ────────────────────
32
33    let in_vm = shell::inside_lima();
34    if in_vm {
35        // Inside Lima VM: limactl is not needed, nix and firecracker are local
36        checks.push(nix_version_check(None));
37        checks.push(check_cmd("firecracker", "tools", "firecracker --version"));
38    } else {
39        // On host: limactl needed for macOS, firecracker checked via Lima
40        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    // Nix flake support check
59    checks.push(nix_flakes_check(in_vm));
60
61    // ── Platform ──────────────────────────────────────────────────
62    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    // Lima VM disk usage (only when Lima is running on macOS)
81    if plat.needs_lima() {
82        checks.push(lima_disk_check());
83    }
84
85    // Nix store health
86    checks.push(nix_store_check(in_vm));
87    checks.push(nix_store_size_check(in_vm));
88
89    // ── Render ────────────────────────────────────────────────────
90    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        // Provide category-specific guidance
111        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
150// ── Tool checks ───────────────────────────────────────────────────────────
151
152fn 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
198// ── Platform checks ───────────────────────────────────────────────────────
199
200fn 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    // Inside Lima VM or native Linux: check /dev/kvm locally
218    if in_vm
219        || plat == Platform::LinuxNative
220        || plat == Platform::LinuxNoKvm
221        || plat == Platform::Wsl2
222    {
223        // Use test -c (character device exists) rather than test -r (readable),
224        // because KVM access may be via group membership which doesn't imply -r.
225        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    // macOS host: check /dev/kvm inside the Lima VM
254    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, // Not a failure — just unavailable
292            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, // Not a failure — just unavailable
310            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
375/// Parse free disk space in GiB from `df` output.
376/// Expects the 4th column of the 2nd line to be the available space with a G suffix.
377fn 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
386// ── Nix checks ────────────────────────────────────────────────────────────
387
388/// Minimum Nix version for flake support (nix build with flakes).
389const NIX_MIN_VERSION: (u64, u64) = (2, 4);
390/// Recommended Nix version for best flake support.
391const NIX_RECOMMENDED_VERSION: (u64, u64) = (2, 13);
392
393/// Check Nix version and validate it meets minimum requirements.
394/// `vm_name`: if Some, run `nix --version` inside the Lima VM; if None, run locally.
395fn 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
462/// Parse "nix (Nix) 2.18.1" or "nix (Nix) 2.24.12 pre-20241211_dirty" into (major, minor, patch).
463fn parse_nix_version(output: &str) -> Option<(u64, u64, u64)> {
464    // Find the version number after "Nix) " or just the last space-separated token
465    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    // Patch may have suffix like "12pre-20241211_dirty"
473    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
483/// Check that Nix flake support is enabled (experimental-features includes nix-command and flakes).
484fn 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
532// ── Lima VM health ────────────────────────────────────────────────────────
533
534/// Check Lima VM disk usage — warn if > 80% full.
535fn 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            // Parse "Use%" column from df output (5th column of 2nd line)
540            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
585// ── Nix store health ──────────────────────────────────────────────────────
586
587/// Check Nix store accessibility via `nix store ping`.
588fn 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            // nix store ping outputs "Store URL: daemon" or similar
600            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
627/// Check Nix store size and warn if it exceeds 20 GiB.
628fn 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; // 20 GiB
641            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        // Version 2.3.x is below minimum 2.4
795        let (major, minor, _patch) = (2, 3, 16);
796        assert!((major, minor) < NIX_MIN_VERSION);
797        // Verify the logic matches what nix_version_check would produce
798        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}