Skip to main content

zagens_runtime/cli/
doctor.rs

1//! Doctor helpers retained for unit tests after CLI/TUI sunset (D6 Phase B).
2
3use std::path::Path;
4
5use crate::config::Config;
6use crate::mcp::McpServerConfig;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub(crate) struct DoctorApiTarget {
10    pub(crate) provider: &'static str,
11    pub(crate) base_url: String,
12    pub(crate) model: String,
13}
14
15pub(crate) fn doctor_api_target(config: &Config) -> DoctorApiTarget {
16    let provider = config.api_provider();
17    DoctorApiTarget {
18        provider: provider.as_str(),
19        base_url: config.deepseek_base_url(),
20        model: config.default_model(),
21    }
22}
23
24pub(crate) fn doctor_timeout_recovery_lines(config: &Config) -> Vec<String> {
25    let target = doctor_api_target(config);
26    let mut lines = vec![format!(
27        "Connection timed out while reaching {}.",
28        target.base_url
29    )];
30
31    match config.api_provider() {
32        crate::config::ApiProvider::Deepseek
33            if target.base_url.contains("api.deepseek.com")
34                && !target.base_url.contains("api.deepseeki.com") =>
35        {
36            lines.push(
37                "If you are in mainland China, set `provider = \"deepseek-cn\"` or `base_url = \"https://api.deepseeki.com\"` in ~/.deepseek/config.toml, then rerun `deepseek doctor`."
38                    .to_string(),
39            );
40        }
41        crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN => {
42            lines.push(
43                "If this is a custom DeepSeek-compatible endpoint, confirm it serves `/v1/models` and `/v1/chat/completions` over HTTPS."
44                    .to_string(),
45            );
46        }
47        _ => {
48            lines.push(
49                "Confirm the configured provider endpoint is reachable and OpenAI-compatible for `/v1/models` and `/v1/chat/completions`."
50                    .to_string(),
51            );
52        }
53    }
54
55    lines.push(
56        "Run `deepseek doctor --json` and include `base_url`, `default_text_model`, and `api_connectivity` when filing an issue."
57            .to_string(),
58    );
59    lines
60}
61
62#[derive(Debug)]
63pub(crate) enum McpServerDoctorStatus {
64    Ok(String),
65    Warning(String),
66    Error(String),
67}
68
69pub(crate) fn doctor_check_mcp_server(server: &McpServerConfig) -> McpServerDoctorStatus {
70    if server.command.is_none() && server.url.is_none() {
71        return McpServerDoctorStatus::Error("no command or url configured".to_string());
72    }
73
74    if let Some(ref url) = server.url {
75        return McpServerDoctorStatus::Ok(format!("HTTP/SSE server at {url}"));
76    }
77
78    let cmd = server.command.as_deref().unwrap_or("");
79    if cmd.is_empty() {
80        return McpServerDoctorStatus::Error("empty command".to_string());
81    }
82
83    let cmd_path = Path::new(cmd);
84    let is_absolute = cmd_path.is_absolute() || cmd.starts_with('/');
85
86    if is_absolute && !cmd_path.exists() {
87        return McpServerDoctorStatus::Error(format!("command not found: {cmd}"));
88    }
89
90    let is_self_hosted = server
91        .args
92        .windows(2)
93        .any(|w| w[0] == "serve" && w[1] == "--mcp");
94
95    let args_str = server.args.join(" ");
96    if is_self_hosted {
97        if is_absolute {
98            McpServerDoctorStatus::Ok(format!("self-hosted MCP server ({cmd} {args_str})"))
99        } else {
100            McpServerDoctorStatus::Warning(format!(
101                "self-hosted MCP server uses relative command \"{cmd}\" — consider using an absolute path"
102            ))
103        }
104    } else {
105        McpServerDoctorStatus::Ok(format!(
106            "stdio server ({cmd}{})",
107            if args_str.is_empty() {
108                String::new()
109            } else {
110                format!(" {args_str}")
111            }
112        ))
113    }
114}