1use std::{net::TcpStream, path::Path, time::Duration};
9
10use serde::{Deserialize, Serialize};
11
12use crate::config::toml_schema::TomlSchema;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19#[non_exhaustive]
20pub enum CheckStatus {
21 Pass,
23 Warn,
25 Fail,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct DoctorCheck {
32 pub name: &'static str,
34 pub status: CheckStatus,
36 pub detail: String,
38 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
79pub 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
94pub 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
122pub 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
156pub 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
169pub 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 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
196pub 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
212pub 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 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
258pub 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
271pub 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
300pub fn check_tls(config_path: &Path) -> DoctorCheck {
302 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
333pub fn check_rls_cache_coherence(config_path: &Path) -> DoctorCheck {
338 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
365pub(crate) fn parse_host_port(url: &str) -> Option<(String, u16)> {
371 let after_scheme = url.split("://").nth(1)?;
373 let host_part = after_scheme.split('/').next()?;
375 let host_port = host_part.split('@').next_back()?;
377
378 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
393pub 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
430pub 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
436pub 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 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 checks.push(check_toml_exists(config_path));
455 if config_path.exists() {
456 checks.push(check_toml_parses(config_path));
457 }
458
459 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 checks.push(check_tls(config_path));
467 checks.push(check_rls_cache_coherence(config_path));
468
469 checks
470}
471
472pub 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}