Skip to main content

fraiseql_cli/commands/
doctor.rs

1//! Doctor command — systematic diagnostic checks for common FraiseQL setup problems.
2//!
3//! Usage:
4//!   fraiseql doctor
5//!   fraiseql doctor --config fraiseql.toml --schema schema.compiled.json
6//!   fraiseql doctor --json
7
8use std::{net::TcpStream, path::Path, time::Duration};
9
10use serde::{Deserialize, Serialize};
11
12use crate::config::toml_schema::TomlSchema;
13
14// ─── Types ────────────────────────────────────────────────────────────────────
15
16/// Outcome of a single diagnostic check.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19#[non_exhaustive]
20pub enum CheckStatus {
21    /// Check passed.
22    Pass,
23    /// Check produced a non-fatal warning.
24    Warn,
25    /// Check failed (fatal).
26    Fail,
27}
28
29/// A single diagnostic check result.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct DoctorCheck {
32    /// Short display name shown in the report.
33    pub name:   &'static str,
34    /// Outcome of the check.
35    pub status: CheckStatus,
36    /// One-line detail appended after the name.
37    pub detail: String,
38    /// Optional actionable hint shown on the next line when status is not Pass.
39    pub hint:   Option<String>,
40}
41
42impl DoctorCheck {
43    pub(crate) fn pass(name: &'static str, detail: impl Into<String>) -> Self {
44        Self {
45            name,
46            status: CheckStatus::Pass,
47            detail: detail.into(),
48            hint: None,
49        }
50    }
51
52    pub(crate) fn warn(
53        name: &'static str,
54        detail: impl Into<String>,
55        hint: impl Into<String>,
56    ) -> Self {
57        Self {
58            name,
59            status: CheckStatus::Warn,
60            detail: detail.into(),
61            hint: Some(hint.into()),
62        }
63    }
64
65    pub(crate) fn fail(
66        name: &'static str,
67        detail: impl Into<String>,
68        hint: impl Into<String>,
69    ) -> Self {
70        Self {
71            name,
72            status: CheckStatus::Fail,
73            detail: detail.into(),
74            hint: Some(hint.into()),
75        }
76    }
77}
78
79// ─── Individual checks ────────────────────────────────────────────────────────
80
81/// Check that the compiled schema file exists and is readable.
82pub fn check_schema_exists(path: &Path) -> DoctorCheck {
83    if path.exists() {
84        DoctorCheck::pass("Schema file exists", path.display().to_string())
85    } else {
86        DoctorCheck::fail(
87            "Schema file exists",
88            format!("not found: {}", path.display()),
89            "Run `fraiseql compile fraiseql.toml` to generate schema.compiled.json",
90        )
91    }
92}
93
94/// Check that the compiled schema file is valid JSON.
95pub fn check_schema_parses(path: &Path) -> DoctorCheck {
96    match std::fs::read_to_string(path) {
97        Err(e) => DoctorCheck::fail(
98            "Schema parses",
99            format!("cannot read: {e}"),
100            "Check file permissions or run `fraiseql compile`",
101        ),
102        Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
103            Err(e) => DoctorCheck::fail(
104                "Schema parses",
105                format!("JSON parse error: {e}"),
106                "Run `fraiseql compile fraiseql.toml` to regenerate the schema",
107            ),
108            Ok(schema) => {
109                let types = schema.get("types").and_then(|v| v.as_array()).map_or(0, Vec::len);
110                let queries = schema.get("queries").and_then(|v| v.as_array()).map_or(0, Vec::len);
111                let mutations =
112                    schema.get("mutations").and_then(|v| v.as_array()).map_or(0, Vec::len);
113                DoctorCheck::pass(
114                    "Schema parses",
115                    format!("types={types}, queries={queries}, mutations={mutations}"),
116                )
117            },
118        },
119    }
120}
121
122/// Check the schema format version field.
123pub fn check_schema_version(path: &Path) -> DoctorCheck {
124    let Ok(content) = std::fs::read_to_string(path) else {
125        return DoctorCheck::warn(
126            "Schema format version",
127            "could not read schema file",
128            "Ensure schema.compiled.json is readable",
129        );
130    };
131    let Ok(schema) = serde_json::from_str::<serde_json::Value>(&content) else {
132        return DoctorCheck::warn(
133            "Schema format version",
134            "schema is not valid JSON — version check skipped",
135            "Run `fraiseql compile` to regenerate",
136        );
137    };
138
139    match schema.get("version").and_then(serde_json::Value::as_u64) {
140        None => DoctorCheck::warn(
141            "Schema format version",
142            "no version field (older schema)",
143            "Run `fraiseql compile fraiseql.toml` to get a versioned schema",
144        ),
145        Some(v) if v == 1 => {
146            DoctorCheck::pass("Schema format version", format!("version={v} (current)"))
147        },
148        Some(v) => DoctorCheck::warn(
149            "Schema format version",
150            format!("version={v} (expected 1)"),
151            "Run `fraiseql compile fraiseql.toml` to recompile with the current compiler",
152        ),
153    }
154}
155
156/// Check whether `fraiseql.toml` exists.
157pub fn check_toml_exists(path: &Path) -> DoctorCheck {
158    if path.exists() {
159        DoctorCheck::pass("fraiseql.toml found", path.display().to_string())
160    } else {
161        DoctorCheck::warn(
162            "fraiseql.toml found",
163            format!("not found: {} (using defaults)", path.display()),
164            "Create fraiseql.toml with `fraiseql init` or provide --config",
165        )
166    }
167}
168
169/// Parse `fraiseql.toml`. Only called when the file actually exists.
170pub fn check_toml_parses(path: &Path) -> DoctorCheck {
171    let content = match std::fs::read_to_string(path) {
172        Ok(c) => c,
173        Err(e) => {
174            return DoctorCheck::fail(
175                "TOML syntax valid",
176                format!("cannot read: {e}"),
177                "Check file permissions",
178            );
179        },
180    };
181    match TomlSchema::parse_toml(&content) {
182        Ok(_) => DoctorCheck::pass("TOML syntax valid", ""),
183        Err(e) => {
184            // Keep only the first line of the error to avoid overwhelming output.
185            let first_line = e.to_string();
186            let short = first_line.lines().next().unwrap_or("parse error");
187            DoctorCheck::fail(
188                "TOML syntax valid",
189                format!("parse error: {short}"),
190                "Fix TOML syntax in fraiseql.toml and retry",
191            )
192        },
193    }
194}
195
196/// Check whether `DATABASE_URL` is set in the environment.
197pub fn check_database_url_set(db_url_override: Option<&str>) -> DoctorCheck {
198    let val = db_url_override
199        .map(std::borrow::Cow::Borrowed)
200        .or_else(|| std::env::var("DATABASE_URL").ok().map(std::borrow::Cow::Owned));
201    if val.is_some() {
202        DoctorCheck::pass("DATABASE_URL set", "")
203    } else {
204        DoctorCheck::fail(
205            "DATABASE_URL set",
206            "not set",
207            "Set DATABASE_URL=postgres://user:pass@host:port/dbname in your environment",
208        )
209    }
210}
211
212/// Attempt a TCP connection to the database host:port extracted from the URL.
213///
214/// This does **not** run any SQL — it only validates that a TCP socket can be
215/// opened within a 5-second timeout.
216pub fn check_db_reachable(db_url_override: Option<&str>) -> DoctorCheck {
217    let url_str = match db_url_override
218        .map(std::borrow::Cow::Borrowed)
219        .or_else(|| std::env::var("DATABASE_URL").ok().map(std::borrow::Cow::Owned))
220    {
221        Some(u) => u.into_owned(),
222        None => {
223            return DoctorCheck::fail(
224                "DATABASE_URL reachable",
225                "DATABASE_URL not set — cannot check connectivity",
226                "Set DATABASE_URL first",
227            );
228        },
229    };
230
231    match parse_host_port(&url_str) {
232        None => DoctorCheck::warn(
233            "DATABASE_URL reachable",
234            format!("could not parse host:port from URL: {url_str}"),
235            "Ensure DATABASE_URL is a valid postgres:// or mysql:// URL",
236        ),
237        Some((host, port)) => {
238            let addr = format!("{host}:{port}");
239            // Parse the socket addr; fall back to a guaranteed-refused addr on parse failure.
240            let sock_addr = addr.parse().unwrap_or_else(|_| {
241                std::net::SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), 0)
242            });
243            match TcpStream::connect_timeout(&sock_addr, Duration::from_secs(5)) {
244                Ok(_) => DoctorCheck::pass("DATABASE_URL reachable", addr),
245                Err(e) => DoctorCheck::fail(
246                    "DATABASE_URL reachable",
247                    format!("connection refused ({addr}): {e}"),
248                    format!(
249                        "Check that the database is running: pg_isready -h {host} -p {port}\n\
250                         Or set DATABASE_URL=postgres://user:pass@host:port/dbname"
251                    ),
252                ),
253            }
254        },
255    }
256}
257
258/// Check whether `FRAISEQL_JWT_SECRET` is set.
259pub fn check_jwt_secret() -> DoctorCheck {
260    if std::env::var("FRAISEQL_JWT_SECRET").is_ok() {
261        DoctorCheck::pass("FRAISEQL_JWT_SECRET", "set")
262    } else {
263        DoctorCheck::warn(
264            "FRAISEQL_JWT_SECRET",
265            "not set (auth will reject all tokens)",
266            "Set FRAISEQL_JWT_SECRET in your environment or .env file",
267        )
268    }
269}
270
271/// Check Redis if `REDIS_URL` is set.
272pub fn check_redis_reachable() -> DoctorCheck {
273    let Ok(url_str) = std::env::var("REDIS_URL") else {
274        return DoctorCheck::pass("FRAISEQL_REDIS_URL", "not set (OK: cache disabled)");
275    };
276
277    match parse_host_port(&url_str) {
278        None => DoctorCheck::warn(
279            "FRAISEQL_REDIS_URL",
280            format!("could not parse host:port from REDIS_URL: {url_str}"),
281            "Ensure REDIS_URL is a valid redis:// URL",
282        ),
283        Some((host, port)) => {
284            let addr = format!("{host}:{port}");
285            let sock_addr = addr.parse().unwrap_or_else(|_| {
286                std::net::SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), 0)
287            });
288            match TcpStream::connect_timeout(&sock_addr, Duration::from_secs(5)) {
289                Ok(_) => DoctorCheck::pass("FRAISEQL_REDIS_URL", format!("reachable ({addr})")),
290                Err(e) => DoctorCheck::fail(
291                    "FRAISEQL_REDIS_URL",
292                    format!("set but not reachable ({addr}): {e}"),
293                    "Check that Redis is running or unset REDIS_URL to disable caching",
294                ),
295            }
296        },
297    }
298}
299
300/// Check TLS: if the TOML config enables TLS, the cert file must exist.
301pub fn check_tls(config_path: &Path) -> DoctorCheck {
302    // Only run this check when the config file exists and is readable.
303    let Ok(content) = std::fs::read_to_string(config_path) else {
304        return DoctorCheck::pass("TLS certificate", "not configured (OK: TLS disabled)");
305    };
306    let Ok(schema) = TomlSchema::parse_toml(&content) else {
307        return DoctorCheck::pass("TLS certificate", "TOML unreadable — TLS check skipped");
308    };
309
310    if !schema.server.tls.enabled {
311        return DoctorCheck::pass("TLS certificate", "not configured (OK: TLS disabled)");
312    }
313
314    let cert = &schema.server.tls.cert_file;
315    if cert.is_empty() {
316        return DoctorCheck::fail(
317            "TLS certificate",
318            "TLS enabled but cert_file is empty",
319            "Set [server.tls] cert_file and key_file in fraiseql.toml",
320        );
321    }
322    if Path::new(cert).exists() {
323        DoctorCheck::pass("TLS certificate", format!("found: {cert}"))
324    } else {
325        DoctorCheck::fail(
326            "TLS certificate",
327            format!("TLS enabled but cert_file not found: {cert}"),
328            "Provide a valid PEM certificate at the configured path",
329        )
330    }
331}
332
333/// Cross-check: warn if caching is enabled without any authorization policy.
334///
335/// When caching is active but no authorization policies are configured, cached
336/// results may be served to unauthenticated users — a potential data-leak.
337pub fn check_rls_cache_coherence(config_path: &Path) -> DoctorCheck {
338    // Config not present — nothing to cross-check.
339    let Ok(content) = std::fs::read_to_string(config_path) else {
340        return DoctorCheck::pass("Cache + auth coherence", "no config (defaults: cache disabled)");
341    };
342    let Ok(schema) = TomlSchema::parse_toml(&content) else {
343        return DoctorCheck::pass("Cache + auth coherence", "TOML unreadable — check skipped");
344    };
345
346    let caching_enabled = schema.caching.enabled;
347    let has_auth_policy =
348        !schema.security.policies.is_empty() || schema.security.default_policy.is_some();
349
350    match (caching_enabled, has_auth_policy) {
351        (false, _) => {
352            DoctorCheck::pass("Cache + auth coherence", "cache disabled — no cross-user risk")
353        },
354        (true, true) => {
355            DoctorCheck::pass("Cache + auth coherence", "caching + auth policy both configured")
356        },
357        (true, false) => DoctorCheck::warn(
358            "Cache + auth coherence",
359            "caching enabled without authorization policy — cached results may leak across users",
360            "Add [security.policies] entries or set [security] default_policy in fraiseql.toml",
361        ),
362    }
363}
364
365// ─── Helpers ─────────────────────────────────────────────────────────────────
366
367/// Extract (host, port) from a URL like `postgres://user:pass@host:5432/db`.
368///
369/// Returns `None` if the URL cannot be parsed.
370pub(crate) fn parse_host_port(url: &str) -> Option<(String, u16)> {
371    // Strip the scheme prefix and credentials; we only need the host:port.
372    let after_scheme = url.split("://").nth(1)?;
373    // Drop path/query after the first `/` following host:port.
374    let host_part = after_scheme.split('/').next()?;
375    // Drop user:pass@.
376    let host_port = host_part.split('@').next_back()?;
377
378    // Handle IPv6 addresses: [::1]:5432
379    if host_port.starts_with('[') {
380        let bracket_end = host_port.find(']')?;
381        let host = host_port[1..bracket_end].to_string();
382        let after_bracket = &host_port[bracket_end + 1..];
383        let port = after_bracket.trim_start_matches(':').parse::<u16>().ok()?;
384        return Some((host, port));
385    }
386
387    let mut parts = host_port.rsplitn(2, ':');
388    let port = parts.next()?.parse::<u16>().ok()?;
389    let host = parts.next().unwrap_or("localhost").to_string();
390    Some((host, port))
391}
392
393// ─── Output ───────────────────────────────────────────────────────────────────
394
395/// Print the doctor report in text format to stdout.
396pub fn print_text_report(checks: &[DoctorCheck]) {
397    println!("\nChecking FraiseQL setup...\n");
398
399    for check in checks {
400        let symbol = match check.status {
401            CheckStatus::Pass => "✓",
402            CheckStatus::Warn => "!",
403            CheckStatus::Fail => "✗",
404        };
405        let detail = if check.detail.is_empty() {
406            String::new()
407        } else {
408            format!("    {}", check.detail)
409        };
410        println!("  [{symbol}] {:<30}{detail}", check.name);
411        if let Some(hint) = &check.hint {
412            for line in hint.lines() {
413                println!("       → {line}");
414            }
415        }
416    }
417
418    let errors = checks.iter().filter(|c| c.status == CheckStatus::Fail).count();
419    let warnings = checks.iter().filter(|c| c.status == CheckStatus::Warn).count();
420
421    println!();
422    match (errors, warnings) {
423        (0, 0) => println!("All checks passed."),
424        (0, w) => println!("Summary: 0 errors, {w} warning(s)"),
425        (e, 0) => println!("Summary: {e} error(s), 0 warnings"),
426        (e, w) => println!("Summary: {e} error(s), {w} warning(s)"),
427    }
428}
429
430/// Print the doctor report as JSON to stdout.
431pub fn print_json_report(checks: &[DoctorCheck]) {
432    let json = serde_json::to_string_pretty(checks).unwrap_or_else(|_| "[]".to_string());
433    println!("{json}");
434}
435
436// ─── Entry point ──────────────────────────────────────────────────────────────
437
438/// Run all doctor checks and return the list of results.
439pub fn run_checks(
440    config_path: &Path,
441    schema_path: &Path,
442    db_url_override: Option<&str>,
443) -> Vec<DoctorCheck> {
444    let mut checks = Vec::new();
445
446    // Schema checks
447    checks.push(check_schema_exists(schema_path));
448    if schema_path.exists() {
449        checks.push(check_schema_parses(schema_path));
450        checks.push(check_schema_version(schema_path));
451    }
452
453    // TOML config checks
454    checks.push(check_toml_exists(config_path));
455    if config_path.exists() {
456        checks.push(check_toml_parses(config_path));
457    }
458
459    // Environment / connectivity checks
460    checks.push(check_database_url_set(db_url_override));
461    checks.push(check_db_reachable(db_url_override));
462    checks.push(check_jwt_secret());
463    checks.push(check_redis_reachable());
464
465    // TLS and coherence checks (only meaningful when config is present)
466    checks.push(check_tls(config_path));
467    checks.push(check_rls_cache_coherence(config_path));
468
469    checks
470}
471
472/// Execute the doctor command.
473///
474/// Returns `true` if all checks passed (exit 0), `false` if any check failed
475/// (exit 1). Warnings do not trigger an exit-1.
476pub fn run(config: &Path, schema: &Path, db_url: Option<&str>, json: bool) -> bool {
477    let checks = run_checks(config, schema, db_url);
478
479    if json {
480        print_json_report(&checks);
481    } else {
482        print_text_report(&checks);
483    }
484
485    checks.iter().all(|c| c.status != CheckStatus::Fail)
486}