Skip to main content

soma_cli/
doctor.rs

1//! doctor — pre-flight environment validation command.
2//!
3//! Pattern §48: Every server binary MUST implement a `doctor` subcommand that
4//! validates the environment and reports what's missing before the user tries
5//! to start the server.
6//!
7//! # Usage
8//!
9//! ```text
10//! example doctor           # human-readable coloured output; exit 0/1
11//! example doctor --json    # machine-readable JSON; exit 0/1
12//! ```
13//!
14//! # CUSTOMIZE
15//!
16//! This is the reference implementation for the soma family. When you
17//! clone Soma for a real service, the things you MUST change are:
18//!
19//! 1. Replace `SOMA_API_URL` / `SOMA_API_KEY` with your service's env vars.
20//! 2. Replace `"soma"` binary name with your binary name in `check_binary_in_path`.
21//! 3. Replace `~/.soma/` data dir with your service's data dir (see `config::default_data_dir`).
22//! 4. Add any service-specific checks (e.g. database connectivity, auth token format).
23//! 5. Update the `print_doctor_report` section headings and hint text to match your service.
24//!
25//! Nothing else here needs changing for a basic deployment. Business logic for the
26//! checks belongs in the individual `check_*` functions — never in `run_doctor`.
27
28mod checks;
29
30use checks::{
31    check_auth_config, check_binary_in_path, check_config_file, check_dir_writable,
32    check_port_available, check_required_var, check_upstream,
33};
34
35use anyhow::{bail, Result};
36use serde::Serialize;
37
38use soma_config::{default_data_dir, Config};
39
40// ── Public entry point ────────────────────────────────────────────────────────
41
42/// Run the doctor command.
43///
44/// Executes all pre-flight checks in order and prints a summary. Exits with
45/// code 1 if any check fails; 0 if all pass.
46///
47/// # CUSTOMIZE
48/// This function is the canonical §48 implementation. Add calls to new
49/// `check_*` functions below to extend the set of checks for your service.
50pub async fn run_doctor(config: &Config, json: bool) -> Result<()> {
51    let mut checks: Vec<DoctorCheck> = Vec::new();
52
53    // ── 1. Config and filesystem ──────────────────────────────────────────────
54    //
55    // CUSTOMIZE: The data dir is resolved via `config::default_data_dir()`.
56    //           In Docker it resolves to /data; bare-metal to ~/.soma/.
57    //           Replace ".example" with your service name in config.rs.
58    let data_dir = default_data_dir()?;
59
60    checks.push(check_config_file(&data_dir));
61    checks.push(check_dir_writable("Data directory", &data_dir));
62    checks.push(check_dir_writable("Log directory", &data_dir.join("logs")));
63
64    // ── 2. Binary in PATH ─────────────────────────────────────────────────────
65    //
66    // CUSTOMIZE: Replace "soma" with your binary name (Cargo.toml [[bin]] name).
67    checks.push(check_binary_in_path("soma"));
68
69    // ── 3. Required environment variables / config ────────────────────────────
70    //
71    // CUSTOMIZE: Replace these with your service's required vars. Mark vars that
72    //           have safe defaults as optional (they will warn, not fail).
73    //
74    // Required vars fail with ✗.  Optional vars warn with ⚠.
75    checks.push(check_required_var("SOMA_API_URL", &config.soma.api_url));
76    checks.push(check_required_var("SOMA_API_KEY", &config.soma.api_key));
77
78    // ── 4. Upstream connectivity ──────────────────────────────────────────────
79    //
80    // CUSTOMIZE: Adjust the health path for your upstream service.
81    //           If the URL is empty we skip the check — the required-var check
82    //           above already flagged it.
83    if !config.soma.api_url.is_empty() {
84        // CUSTOMIZE: Replace "/health" with your upstream's health or ping endpoint.
85        //           If your upstream has no health endpoint, do a simple HEAD / request.
86        checks.push(check_upstream(&config.soma.api_url).await);
87    }
88
89    // ── 5. MCP server port ────────────────────────────────────────────────────
90    //
91    // CUSTOMIZE: config.mcp.port defaults to 3000 for Soma.
92    //           Your service's port is set in config.toml [mcp] port.
93    checks.push(check_port_available(&config.mcp.host, config.mcp.port));
94
95    // ── 6. Auth configuration ─────────────────────────────────────────────────
96    //
97    // CUSTOMIZE: The auth check inspects the combination of host / auth settings
98    //           and reports which auth mode is active, or warns if 0.0.0.0 has
99    //           no auth configured.
100    checks.push(check_auth_config(config));
101
102    // ── Render output ─────────────────────────────────────────────────────────
103
104    let issues = checks.iter().filter(|c| !c.ok).count();
105
106    let format = soma_cli_core::output::OutputFormat::from_json_flag(json);
107    if format.is_json() {
108        println!("{}", soma_cli_core::json::to_pretty_string(&checks)?);
109    } else {
110        print_doctor_report(&checks);
111    }
112
113    // Exit code 1 when any check fails.
114    if issues > 0 {
115        bail!("doctor found {issues} issue(s)");
116    }
117    Ok(())
118}
119
120// ── DoctorCheck struct ────────────────────────────────────────────────────────
121
122/// A single pre-flight check result.
123///
124/// `ok = true`  → the check passed; `value` shows what was found.
125/// `ok = false` → the check failed; `hint` explains how to fix it.
126///
127/// # CUSTOMIZE
128/// Serialises directly to the `--json` output. Add fields here if you need
129/// additional metadata (e.g. `severity: "warning" | "error"`, `doc_url`).
130#[derive(Debug, Serialize)]
131pub struct DoctorCheck {
132    /// Logical category for grouping in human output and JSON filtering.
133    ///
134    /// CUSTOMIZE: Defined by each `check_*` function. Categories in Soma:
135    ///   "config" | "credentials" | "connectivity" | "server" | "auth"
136    pub category: &'static str,
137
138    /// Short human-readable name for the check (shown in the left column).
139    pub name: String,
140
141    /// `true` = passed (✓), `false` = failed (✗).
142    pub ok: bool,
143
144    /// What was found — shown in the right column when ok=true.
145    /// For failed checks, the hint is more useful.
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub value: Option<String>,
148
149    /// How to fix the problem — only present when `ok = false`.
150    ///
151    /// CUSTOMIZE: Make hints actionable — tell the user exactly what to type.
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub hint: Option<String>,
154
155    /// Round-trip latency in milliseconds — only for connectivity checks.
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub latency_ms: Option<u64>,
158}
159
160impl DoctorCheck {
161    fn pass(category: &'static str, name: impl Into<String>, value: impl Into<String>) -> Self {
162        Self {
163            category,
164            name: name.into(),
165            ok: true,
166            value: Some(value.into()),
167            hint: None,
168            latency_ms: None,
169        }
170    }
171
172    fn fail(category: &'static str, name: impl Into<String>, hint: impl Into<String>) -> Self {
173        Self {
174            category,
175            name: name.into(),
176            ok: false,
177            value: None,
178            hint: Some(hint.into()),
179            latency_ms: None,
180        }
181    }
182
183    fn pass_with_latency(
184        category: &'static str,
185        name: impl Into<String>,
186        value: impl Into<String>,
187        latency_ms: u64,
188    ) -> Self {
189        Self {
190            category,
191            name: name.into(),
192            ok: true,
193            value: Some(value.into()),
194            hint: None,
195            latency_ms: Some(latency_ms),
196        }
197    }
198
199    fn fail_with_latency(
200        category: &'static str,
201        name: impl Into<String>,
202        hint: impl Into<String>,
203        latency_ms: u64,
204    ) -> Self {
205        Self {
206            category,
207            name: name.into(),
208            ok: false,
209            value: None,
210            hint: Some(hint.into()),
211            latency_ms: Some(latency_ms),
212        }
213    }
214}
215
216// ── Human-readable report ─────────────────────────────────────────────────────
217
218/// Print the doctor report in human-readable coloured format.
219///
220/// Output follows the §48 layout:
221///
222/// ```text
223/// soma-mcp v0.1.0 — environment check
224///
225///   Config
226///   ────────────────────────────────────────────
227///   ✓ Config file:  ~/.soma/config.toml
228///   ✗ Data dir:     not writable
229///     → Fix: chmod u+w ~/.soma
230///   ...
231/// ```
232///
233/// # CUSTOMIZE
234/// Section headings and the version string are the main things to customise.
235/// Add new sections if you add new check categories beyond the five defaults.
236fn print_doctor_report(checks: &[DoctorCheck]) {
237    let color = soma_cli_core::terminal::stderr_supports_color();
238
239    // ── ANSI helpers ──────────────────────────────────────────────────────────
240    // Delegates to soma-cli-core's color policy (crates/shared/cli-core);
241    // wrapped in local macros so call sites below stay unchanged.
242    macro_rules! green {
243        ($s:expr) => {
244            soma_cli_core::color::green(&$s.to_string(), color)
245        };
246    }
247    macro_rules! red {
248        ($s:expr) => {
249            soma_cli_core::color::red(&$s.to_string(), color)
250        };
251    }
252    macro_rules! yellow {
253        ($s:expr) => {
254            soma_cli_core::color::yellow(&$s.to_string(), color)
255        };
256    }
257    macro_rules! bold {
258        ($s:expr) => {
259            soma_cli_core::color::bold(&$s.to_string(), color)
260        };
261    }
262    macro_rules! dim {
263        ($s:expr) => {
264            soma_cli_core::color::dim(&$s.to_string(), color)
265        };
266    }
267
268    // CUSTOMIZE: Replace "soma-mcp" with your service name and binary name.
269    println!();
270    println!(
271        "{}",
272        bold!(format!(
273            "soma-mcp v{} — environment check",
274            env!("CARGO_PKG_VERSION")
275        ))
276    );
277    println!();
278
279    // Group checks by category and print in order.
280    // CUSTOMIZE: Reorder categories or add new ones to match your service.
281    let categories: &[(&str, &str)] = &[
282        ("config", "Config"),
283        ("credentials", "Service credentials"),
284        ("connectivity", "Connectivity"),
285        ("server", "MCP server"),
286        ("auth", "Authentication"),
287    ];
288
289    for (cat_key, cat_label) in categories {
290        let cat_checks: Vec<&DoctorCheck> =
291            checks.iter().filter(|c| c.category == *cat_key).collect();
292        if cat_checks.is_empty() {
293            continue;
294        }
295
296        println!("  {}", bold!(cat_label));
297        println!("  {}", dim!("─".repeat(44)));
298
299        for check in &cat_checks {
300            if check.ok {
301                let value = check.value.as_deref().unwrap_or("");
302                let latency = check
303                    .latency_ms
304                    .map(|ms| format!(" ({ms} ms)"))
305                    .unwrap_or_default();
306                println!(
307                    "  {}  {:<28}  {}{}",
308                    green!("✓"),
309                    check.name,
310                    value,
311                    latency
312                );
313            } else {
314                println!("  {}  {}", red!("✗"), check.name);
315                if let Some(hint) = &check.hint {
316                    for line in hint.lines() {
317                        println!("    {}", yellow!(line));
318                    }
319                }
320            }
321        }
322
323        println!();
324    }
325
326    // ── Summary line ──────────────────────────────────────────────────────────
327    let issues = checks.iter().filter(|c| !c.ok).count();
328    println!("  {}", dim!("━".repeat(44)));
329
330    if issues == 0 {
331        println!(
332            "  {}  All checks passed. Run: {}",
333            green!("✓"),
334            bold!("soma serve")
335        );
336    } else {
337        // CUSTOMIZE: Replace "soma serve" with your server command.
338        let noun = if issues == 1 { "issue" } else { "issues" };
339        println!(
340            "  {}  {} {noun} found. Fix before running: {}",
341            red!("✗"),
342            red!(issues.to_string()),
343            bold!("soma serve")
344        );
345    }
346    println!();
347}
348
349#[cfg(test)]
350#[path = "doctor_tests.rs"]
351mod tests;