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;