Skip to main content

ferrum_cli/commands/
doctor.rs

1//! Read-only first-run diagnostics.
2
3use crate::config::CliConfig;
4use clap::Args;
5use ferrum_types::Result;
6use std::process::Command;
7
8#[derive(Args)]
9pub struct DoctorCommand {
10    /// Optional model alias, Hugging Face repository, or GGUF path to inspect.
11    #[arg(value_name = "MODEL")]
12    pub model: Option<String>,
13}
14
15pub async fn execute(cmd: DoctorCommand, config: CliConfig) -> Result<()> {
16    println!("Ferrum {}", env!("CARGO_PKG_VERSION"));
17    println!(
18        "Platform: {} {}",
19        std::env::consts::OS,
20        std::env::consts::ARCH
21    );
22
23    let accelerators = compiled_accelerators();
24    if accelerators.is_empty() {
25        println!("Compiled acceleration: none (CPU/default build)");
26    } else {
27        println!("Compiled acceleration: {}", accelerators.join(", "));
28    }
29
30    if cfg!(feature = "cuda") {
31        match cuda_devices() {
32            Some(devices) => println!("CUDA devices: {devices}"),
33            None => println!(
34                "CUDA devices: not visible; check the NVIDIA driver and `nvidia-smi` before loading a model"
35            ),
36        }
37    }
38
39    let cache = crate::source_resolver::hf_cache_dir(&config);
40    println!("Model cache: {}", cache.display());
41    println!(
42        "Cache status: {}",
43        if cache.exists() {
44            "present"
45        } else {
46            "not created yet"
47        }
48    );
49
50    println!();
51    if let Some(model) = cmd.model.as_deref() {
52        let (source, format) = describe_model(model);
53        println!("Requested model: {model}");
54        println!("Resolved source: {source}");
55        println!("Expected format: {format}");
56        println!("No model was downloaded and no inference engine was started.");
57        println!();
58        println!("Next:");
59        println!("  ferrum run {model}");
60        println!("  ferrum serve --model {model} --served-model-name ferrum --port 8000");
61    } else {
62        println!("Recommended first model:");
63        if cfg!(feature = "metal") {
64            println!(
65                "  Metal: ferrum run {}",
66                crate::source_resolver::METAL_FIRST_SUCCESS_MODEL
67            );
68        }
69        if cfg!(feature = "cuda") {
70            println!(
71                "  CUDA:  ferrum run {}",
72                crate::source_resolver::CUDA_FIRST_SUCCESS_MODEL
73            );
74        }
75        if accelerators.is_empty() {
76            println!("  Install a Metal or CUDA build for the v0.8 accelerator paths.");
77        }
78        println!("Pass a model to inspect its source without downloading it.");
79    }
80
81    Ok(())
82}
83
84fn compiled_accelerators() -> Vec<&'static str> {
85    let mut accelerators = Vec::new();
86    if cfg!(feature = "metal") {
87        accelerators.push("metal");
88    }
89    if cfg!(feature = "cuda") {
90        accelerators.push("cuda");
91    }
92    accelerators
93}
94
95fn cuda_devices() -> Option<String> {
96    let output = Command::new("nvidia-smi")
97        .args([
98            "--query-gpu=name,memory.total",
99            "--format=csv,noheader,nounits",
100        ])
101        .output()
102        .ok()?;
103    if !output.status.success() {
104        return None;
105    }
106    let devices = String::from_utf8_lossy(&output.stdout).trim().to_string();
107    (!devices.is_empty()).then_some(devices)
108}
109
110fn describe_model(model: &str) -> (String, &'static str) {
111    if let Some((repo, filename)) = crate::source_resolver::resolve_gguf_alias(model) {
112        return (format!("{repo} / {filename}"), "GGUF");
113    }
114
115    let source = crate::source_resolver::resolve_model_alias(model);
116    let format = if model.to_ascii_lowercase().ends_with(".gguf") {
117        "GGUF"
118    } else {
119        "repository weights (resolved at startup)"
120    };
121    (source, format)
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn doctor_reuses_curated_gguf_resolution() {
130        let (source, format) = describe_model("qwen3.5:4b-q4_k_m");
131        assert_eq!(format, "GGUF");
132        assert!(source.contains("unsloth/Qwen3.5-4B-GGUF"));
133        assert!(source.contains("Qwen3.5-4B-Q4_K_M.gguf"));
134    }
135
136    #[test]
137    fn doctor_reuses_hf_alias_resolution() {
138        let (source, format) = describe_model("qwen3.5:4b");
139        assert_eq!(source, "Qwen/Qwen3.5-4B");
140        assert_eq!(format, "repository weights (resolved at startup)");
141    }
142}