use std::{net::TcpStream, path::Path, time::Duration};
use serde::{Deserialize, Serialize};
use crate::config::toml_schema::TomlSchema;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum CheckStatus {
Pass,
Warn,
Fail,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DoctorCheck {
pub name: &'static str,
pub status: CheckStatus,
pub detail: String,
pub hint: Option<String>,
}
impl DoctorCheck {
pub(crate) fn pass(name: &'static str, detail: impl Into<String>) -> Self {
Self {
name,
status: CheckStatus::Pass,
detail: detail.into(),
hint: None,
}
}
pub(crate) fn warn(
name: &'static str,
detail: impl Into<String>,
hint: impl Into<String>,
) -> Self {
Self {
name,
status: CheckStatus::Warn,
detail: detail.into(),
hint: Some(hint.into()),
}
}
pub(crate) fn fail(
name: &'static str,
detail: impl Into<String>,
hint: impl Into<String>,
) -> Self {
Self {
name,
status: CheckStatus::Fail,
detail: detail.into(),
hint: Some(hint.into()),
}
}
}
pub fn check_schema_exists(path: &Path) -> DoctorCheck {
if path.exists() {
DoctorCheck::pass("Schema file exists", path.display().to_string())
} else {
DoctorCheck::fail(
"Schema file exists",
format!("not found: {}", path.display()),
"Run `fraiseql compile fraiseql.toml` to generate schema.compiled.json",
)
}
}
pub fn check_schema_parses(path: &Path) -> DoctorCheck {
match std::fs::read_to_string(path) {
Err(e) => DoctorCheck::fail(
"Schema parses",
format!("cannot read: {e}"),
"Check file permissions or run `fraiseql compile`",
),
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
Err(e) => DoctorCheck::fail(
"Schema parses",
format!("JSON parse error: {e}"),
"Run `fraiseql compile fraiseql.toml` to regenerate the schema",
),
Ok(schema) => {
let types = schema.get("types").and_then(|v| v.as_array()).map_or(0, Vec::len);
let queries = schema.get("queries").and_then(|v| v.as_array()).map_or(0, Vec::len);
let mutations =
schema.get("mutations").and_then(|v| v.as_array()).map_or(0, Vec::len);
DoctorCheck::pass(
"Schema parses",
format!("types={types}, queries={queries}, mutations={mutations}"),
)
},
},
}
}
pub fn check_schema_version(path: &Path) -> DoctorCheck {
let Ok(content) = std::fs::read_to_string(path) else {
return DoctorCheck::warn(
"Schema format version",
"could not read schema file",
"Ensure schema.compiled.json is readable",
);
};
let Ok(schema) = serde_json::from_str::<serde_json::Value>(&content) else {
return DoctorCheck::warn(
"Schema format version",
"schema is not valid JSON — version check skipped",
"Run `fraiseql compile` to regenerate",
);
};
match schema.get("version").and_then(serde_json::Value::as_u64) {
None => DoctorCheck::warn(
"Schema format version",
"no version field (older schema)",
"Run `fraiseql compile fraiseql.toml` to get a versioned schema",
),
Some(v) if v == 1 => {
DoctorCheck::pass("Schema format version", format!("version={v} (current)"))
},
Some(v) => DoctorCheck::warn(
"Schema format version",
format!("version={v} (expected 1)"),
"Run `fraiseql compile fraiseql.toml` to recompile with the current compiler",
),
}
}
pub fn check_toml_exists(path: &Path) -> DoctorCheck {
if path.exists() {
DoctorCheck::pass("fraiseql.toml found", path.display().to_string())
} else {
DoctorCheck::warn(
"fraiseql.toml found",
format!("not found: {} (using defaults)", path.display()),
"Create fraiseql.toml with `fraiseql init` or provide --config",
)
}
}
pub fn check_toml_parses(path: &Path) -> DoctorCheck {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
return DoctorCheck::fail(
"TOML syntax valid",
format!("cannot read: {e}"),
"Check file permissions",
);
},
};
match TomlSchema::parse_toml(&content) {
Ok(_) => DoctorCheck::pass("TOML syntax valid", ""),
Err(e) => {
let first_line = e.to_string();
let short = first_line.lines().next().unwrap_or("parse error");
DoctorCheck::fail(
"TOML syntax valid",
format!("parse error: {short}"),
"Fix TOML syntax in fraiseql.toml and retry",
)
},
}
}
pub fn check_database_url_set(db_url_override: Option<&str>) -> DoctorCheck {
let val = db_url_override
.map(std::borrow::Cow::Borrowed)
.or_else(|| std::env::var("DATABASE_URL").ok().map(std::borrow::Cow::Owned));
if val.is_some() {
DoctorCheck::pass("DATABASE_URL set", "")
} else {
DoctorCheck::fail(
"DATABASE_URL set",
"not set",
"Set DATABASE_URL=postgres://user:pass@host:port/dbname in your environment",
)
}
}
pub fn check_db_reachable(db_url_override: Option<&str>) -> DoctorCheck {
let url_str = match db_url_override
.map(std::borrow::Cow::Borrowed)
.or_else(|| std::env::var("DATABASE_URL").ok().map(std::borrow::Cow::Owned))
{
Some(u) => u.into_owned(),
None => {
return DoctorCheck::fail(
"DATABASE_URL reachable",
"DATABASE_URL not set — cannot check connectivity",
"Set DATABASE_URL first",
);
},
};
match parse_host_port(&url_str) {
None => DoctorCheck::warn(
"DATABASE_URL reachable",
format!("could not parse host:port from URL: {url_str}"),
"Ensure DATABASE_URL is a valid postgres:// or mysql:// URL",
),
Some((host, port)) => {
let addr = format!("{host}:{port}");
let sock_addr = addr.parse().unwrap_or_else(|_| {
std::net::SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), 0)
});
match TcpStream::connect_timeout(&sock_addr, Duration::from_secs(5)) {
Ok(_) => DoctorCheck::pass("DATABASE_URL reachable", addr),
Err(e) => DoctorCheck::fail(
"DATABASE_URL reachable",
format!("connection refused ({addr}): {e}"),
format!(
"Check that the database is running: pg_isready -h {host} -p {port}\n\
Or set DATABASE_URL=postgres://user:pass@host:port/dbname"
),
),
}
},
}
}
pub fn check_jwt_secret() -> DoctorCheck {
if std::env::var("FRAISEQL_JWT_SECRET").is_ok() {
DoctorCheck::pass("FRAISEQL_JWT_SECRET", "set")
} else {
DoctorCheck::warn(
"FRAISEQL_JWT_SECRET",
"not set (auth will reject all tokens)",
"Set FRAISEQL_JWT_SECRET in your environment or .env file",
)
}
}
pub fn check_redis_reachable() -> DoctorCheck {
let Ok(url_str) = std::env::var("REDIS_URL") else {
return DoctorCheck::pass("FRAISEQL_REDIS_URL", "not set (OK: cache disabled)");
};
match parse_host_port(&url_str) {
None => DoctorCheck::warn(
"FRAISEQL_REDIS_URL",
format!("could not parse host:port from REDIS_URL: {url_str}"),
"Ensure REDIS_URL is a valid redis:// URL",
),
Some((host, port)) => {
let addr = format!("{host}:{port}");
let sock_addr = addr.parse().unwrap_or_else(|_| {
std::net::SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), 0)
});
match TcpStream::connect_timeout(&sock_addr, Duration::from_secs(5)) {
Ok(_) => DoctorCheck::pass("FRAISEQL_REDIS_URL", format!("reachable ({addr})")),
Err(e) => DoctorCheck::fail(
"FRAISEQL_REDIS_URL",
format!("set but not reachable ({addr}): {e}"),
"Check that Redis is running or unset REDIS_URL to disable caching",
),
}
},
}
}
pub fn check_tls(config_path: &Path) -> DoctorCheck {
let Ok(content) = std::fs::read_to_string(config_path) else {
return DoctorCheck::pass("TLS certificate", "not configured (OK: TLS disabled)");
};
let Ok(schema) = TomlSchema::parse_toml(&content) else {
return DoctorCheck::pass("TLS certificate", "TOML unreadable — TLS check skipped");
};
if !schema.server.tls.enabled {
return DoctorCheck::pass("TLS certificate", "not configured (OK: TLS disabled)");
}
let cert = &schema.server.tls.cert_file;
if cert.is_empty() {
return DoctorCheck::fail(
"TLS certificate",
"TLS enabled but cert_file is empty",
"Set [server.tls] cert_file and key_file in fraiseql.toml",
);
}
if Path::new(cert).exists() {
DoctorCheck::pass("TLS certificate", format!("found: {cert}"))
} else {
DoctorCheck::fail(
"TLS certificate",
format!("TLS enabled but cert_file not found: {cert}"),
"Provide a valid PEM certificate at the configured path",
)
}
}
pub fn check_rls_cache_coherence(config_path: &Path) -> DoctorCheck {
let Ok(content) = std::fs::read_to_string(config_path) else {
return DoctorCheck::pass("Cache + auth coherence", "no config (defaults: cache disabled)");
};
let Ok(schema) = TomlSchema::parse_toml(&content) else {
return DoctorCheck::pass("Cache + auth coherence", "TOML unreadable — check skipped");
};
let caching_enabled = schema.caching.enabled;
let has_auth_policy =
!schema.security.policies.is_empty() || schema.security.default_policy.is_some();
match (caching_enabled, has_auth_policy) {
(false, _) => {
DoctorCheck::pass("Cache + auth coherence", "cache disabled — no cross-user risk")
},
(true, true) => {
DoctorCheck::pass("Cache + auth coherence", "caching + auth policy both configured")
},
(true, false) => DoctorCheck::warn(
"Cache + auth coherence",
"caching enabled without authorization policy — cached results may leak across users",
"Add [security.policies] entries or set [security] default_policy in fraiseql.toml",
),
}
}
pub(crate) fn parse_host_port(url: &str) -> Option<(String, u16)> {
let after_scheme = url.split("://").nth(1)?;
let host_part = after_scheme.split('/').next()?;
let host_port = host_part.split('@').next_back()?;
if host_port.starts_with('[') {
let bracket_end = host_port.find(']')?;
let host = host_port[1..bracket_end].to_string();
let after_bracket = &host_port[bracket_end + 1..];
let port = after_bracket.trim_start_matches(':').parse::<u16>().ok()?;
return Some((host, port));
}
let mut parts = host_port.rsplitn(2, ':');
let port = parts.next()?.parse::<u16>().ok()?;
let host = parts.next().unwrap_or("localhost").to_string();
Some((host, port))
}
pub fn print_text_report(checks: &[DoctorCheck]) {
println!("\nChecking FraiseQL setup...\n");
for check in checks {
let symbol = match check.status {
CheckStatus::Pass => "✓",
CheckStatus::Warn => "!",
CheckStatus::Fail => "✗",
};
let detail = if check.detail.is_empty() {
String::new()
} else {
format!(" {}", check.detail)
};
println!(" [{symbol}] {:<30}{detail}", check.name);
if let Some(hint) = &check.hint {
for line in hint.lines() {
println!(" → {line}");
}
}
}
let errors = checks.iter().filter(|c| c.status == CheckStatus::Fail).count();
let warnings = checks.iter().filter(|c| c.status == CheckStatus::Warn).count();
println!();
match (errors, warnings) {
(0, 0) => println!("All checks passed."),
(0, w) => println!("Summary: 0 errors, {w} warning(s)"),
(e, 0) => println!("Summary: {e} error(s), 0 warnings"),
(e, w) => println!("Summary: {e} error(s), {w} warning(s)"),
}
}
pub fn print_json_report(checks: &[DoctorCheck]) {
let json = serde_json::to_string_pretty(checks).unwrap_or_else(|_| "[]".to_string());
println!("{json}");
}
pub fn run_checks(
config_path: &Path,
schema_path: &Path,
db_url_override: Option<&str>,
) -> Vec<DoctorCheck> {
let mut checks = Vec::new();
checks.push(check_schema_exists(schema_path));
if schema_path.exists() {
checks.push(check_schema_parses(schema_path));
checks.push(check_schema_version(schema_path));
}
checks.push(check_toml_exists(config_path));
if config_path.exists() {
checks.push(check_toml_parses(config_path));
}
checks.push(check_database_url_set(db_url_override));
checks.push(check_db_reachable(db_url_override));
checks.push(check_jwt_secret());
checks.push(check_redis_reachable());
checks.push(check_tls(config_path));
checks.push(check_rls_cache_coherence(config_path));
checks
}
pub fn run(config: &Path, schema: &Path, db_url: Option<&str>, json: bool) -> bool {
let checks = run_checks(config, schema, db_url);
if json {
print_json_report(&checks);
} else {
print_text_report(&checks);
}
checks.iter().all(|c| c.status != CheckStatus::Fail)
}