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 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 fn warn(name: &'static str, detail: impl Into<String>, hint: impl Into<String>) -> Self {
53 Self {
54 name,
55 status: CheckStatus::Warn,
56 detail: detail.into(),
57 hint: Some(hint.into()),
58 }
59 }
60
61 fn fail(name: &'static str, detail: impl Into<String>, hint: impl Into<String>) -> Self {
62 Self {
63 name,
64 status: CheckStatus::Fail,
65 detail: detail.into(),
66 hint: Some(hint.into()),
67 }
68 }
69}
70
71pub fn check_schema_exists(path: &Path) -> DoctorCheck {
75 if path.exists() {
76 DoctorCheck::pass("Schema file exists", path.display().to_string())
77 } else {
78 DoctorCheck::fail(
79 "Schema file exists",
80 format!("not found: {}", path.display()),
81 "Run `fraiseql compile fraiseql.toml` to generate schema.compiled.json",
82 )
83 }
84}
85
86pub fn check_schema_parses(path: &Path) -> DoctorCheck {
88 match std::fs::read_to_string(path) {
89 Err(e) => DoctorCheck::fail(
90 "Schema parses",
91 format!("cannot read: {e}"),
92 "Check file permissions or run `fraiseql compile`",
93 ),
94 Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
95 Err(e) => DoctorCheck::fail(
96 "Schema parses",
97 format!("JSON parse error: {e}"),
98 "Run `fraiseql compile fraiseql.toml` to regenerate the schema",
99 ),
100 Ok(schema) => {
101 let types = schema.get("types").and_then(|v| v.as_array()).map_or(0, Vec::len);
102 let queries = schema.get("queries").and_then(|v| v.as_array()).map_or(0, Vec::len);
103 let mutations =
104 schema.get("mutations").and_then(|v| v.as_array()).map_or(0, Vec::len);
105 DoctorCheck::pass(
106 "Schema parses",
107 format!("types={types}, queries={queries}, mutations={mutations}"),
108 )
109 },
110 },
111 }
112}
113
114pub fn check_schema_version(path: &Path) -> DoctorCheck {
116 let Ok(content) = std::fs::read_to_string(path) else {
117 return DoctorCheck::warn(
118 "Schema format version",
119 "could not read schema file",
120 "Ensure schema.compiled.json is readable",
121 );
122 };
123 let Ok(schema) = serde_json::from_str::<serde_json::Value>(&content) else {
124 return DoctorCheck::warn(
125 "Schema format version",
126 "schema is not valid JSON — version check skipped",
127 "Run `fraiseql compile` to regenerate",
128 );
129 };
130
131 match schema.get("version").and_then(serde_json::Value::as_u64) {
132 None => DoctorCheck::warn(
133 "Schema format version",
134 "no version field (older schema)",
135 "Run `fraiseql compile fraiseql.toml` to get a versioned schema",
136 ),
137 Some(v) if v == 1 => {
138 DoctorCheck::pass("Schema format version", format!("version={v} (current)"))
139 },
140 Some(v) => DoctorCheck::warn(
141 "Schema format version",
142 format!("version={v} (expected 1)"),
143 "Run `fraiseql compile fraiseql.toml` to recompile with the current compiler",
144 ),
145 }
146}
147
148pub fn check_toml_exists(path: &Path) -> DoctorCheck {
150 if path.exists() {
151 DoctorCheck::pass("fraiseql.toml found", path.display().to_string())
152 } else {
153 DoctorCheck::warn(
154 "fraiseql.toml found",
155 format!("not found: {} (using defaults)", path.display()),
156 "Create fraiseql.toml with `fraiseql init` or provide --config",
157 )
158 }
159}
160
161pub fn check_toml_parses(path: &Path) -> DoctorCheck {
163 let content = match std::fs::read_to_string(path) {
164 Ok(c) => c,
165 Err(e) => {
166 return DoctorCheck::fail(
167 "TOML syntax valid",
168 format!("cannot read: {e}"),
169 "Check file permissions",
170 );
171 },
172 };
173 match TomlSchema::parse_toml(&content) {
174 Ok(_) => DoctorCheck::pass("TOML syntax valid", ""),
175 Err(e) => {
176 let first_line = e.to_string();
178 let short = first_line.lines().next().unwrap_or("parse error");
179 DoctorCheck::fail(
180 "TOML syntax valid",
181 format!("parse error: {short}"),
182 "Fix TOML syntax in fraiseql.toml and retry",
183 )
184 },
185 }
186}
187
188pub fn check_database_url_set(db_url_override: Option<&str>) -> DoctorCheck {
190 let val = db_url_override
191 .map(std::borrow::Cow::Borrowed)
192 .or_else(|| std::env::var("DATABASE_URL").ok().map(std::borrow::Cow::Owned));
193 if val.is_some() {
194 DoctorCheck::pass("DATABASE_URL set", "")
195 } else {
196 DoctorCheck::fail(
197 "DATABASE_URL set",
198 "not set",
199 "Set DATABASE_URL=postgres://user:pass@host:port/dbname in your environment",
200 )
201 }
202}
203
204pub fn check_db_reachable(db_url_override: Option<&str>) -> DoctorCheck {
209 let url_str = match db_url_override
210 .map(std::borrow::Cow::Borrowed)
211 .or_else(|| std::env::var("DATABASE_URL").ok().map(std::borrow::Cow::Owned))
212 {
213 Some(u) => u.into_owned(),
214 None => {
215 return DoctorCheck::fail(
216 "DATABASE_URL reachable",
217 "DATABASE_URL not set — cannot check connectivity",
218 "Set DATABASE_URL first",
219 );
220 },
221 };
222
223 match parse_host_port(&url_str) {
224 None => DoctorCheck::warn(
225 "DATABASE_URL reachable",
226 format!("could not parse host:port from URL: {url_str}"),
227 "Ensure DATABASE_URL is a valid postgres:// or mysql:// URL",
228 ),
229 Some((host, port)) => {
230 let addr = format!("{host}:{port}");
231 let sock_addr = addr.parse().unwrap_or_else(|_| {
233 std::net::SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), 0)
234 });
235 match TcpStream::connect_timeout(&sock_addr, Duration::from_secs(5)) {
236 Ok(_) => DoctorCheck::pass("DATABASE_URL reachable", addr),
237 Err(e) => DoctorCheck::fail(
238 "DATABASE_URL reachable",
239 format!("connection refused ({addr}): {e}"),
240 format!(
241 "Check that the database is running: pg_isready -h {host} -p {port}\n\
242 Or set DATABASE_URL=postgres://user:pass@host:port/dbname"
243 ),
244 ),
245 }
246 },
247 }
248}
249
250pub fn check_jwt_secret() -> DoctorCheck {
252 if std::env::var("FRAISEQL_JWT_SECRET").is_ok() {
253 DoctorCheck::pass("FRAISEQL_JWT_SECRET", "set")
254 } else {
255 DoctorCheck::warn(
256 "FRAISEQL_JWT_SECRET",
257 "not set (auth will reject all tokens)",
258 "Set FRAISEQL_JWT_SECRET in your environment or .env file",
259 )
260 }
261}
262
263pub fn check_redis_reachable() -> DoctorCheck {
265 let Ok(url_str) = std::env::var("REDIS_URL") else {
266 return DoctorCheck::pass("FRAISEQL_REDIS_URL", "not set (OK: cache disabled)");
267 };
268
269 match parse_host_port(&url_str) {
270 None => DoctorCheck::warn(
271 "FRAISEQL_REDIS_URL",
272 format!("could not parse host:port from REDIS_URL: {url_str}"),
273 "Ensure REDIS_URL is a valid redis:// URL",
274 ),
275 Some((host, port)) => {
276 let addr = format!("{host}:{port}");
277 let sock_addr = addr.parse().unwrap_or_else(|_| {
278 std::net::SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), 0)
279 });
280 match TcpStream::connect_timeout(&sock_addr, Duration::from_secs(5)) {
281 Ok(_) => DoctorCheck::pass("FRAISEQL_REDIS_URL", format!("reachable ({addr})")),
282 Err(e) => DoctorCheck::fail(
283 "FRAISEQL_REDIS_URL",
284 format!("set but not reachable ({addr}): {e}"),
285 "Check that Redis is running or unset REDIS_URL to disable caching",
286 ),
287 }
288 },
289 }
290}
291
292pub fn check_tls(config_path: &Path) -> DoctorCheck {
294 let Ok(content) = std::fs::read_to_string(config_path) else {
296 return DoctorCheck::pass("TLS certificate", "not configured (OK: TLS disabled)");
297 };
298 let Ok(schema) = TomlSchema::parse_toml(&content) else {
299 return DoctorCheck::pass("TLS certificate", "TOML unreadable — TLS check skipped");
300 };
301
302 if !schema.server.tls.enabled {
303 return DoctorCheck::pass("TLS certificate", "not configured (OK: TLS disabled)");
304 }
305
306 let cert = &schema.server.tls.cert_file;
307 if cert.is_empty() {
308 return DoctorCheck::fail(
309 "TLS certificate",
310 "TLS enabled but cert_file is empty",
311 "Set [server.tls] cert_file and key_file in fraiseql.toml",
312 );
313 }
314 if Path::new(cert).exists() {
315 DoctorCheck::pass("TLS certificate", format!("found: {cert}"))
316 } else {
317 DoctorCheck::fail(
318 "TLS certificate",
319 format!("TLS enabled but cert_file not found: {cert}"),
320 "Provide a valid PEM certificate at the configured path",
321 )
322 }
323}
324
325pub fn check_rls_cache_coherence(config_path: &Path) -> DoctorCheck {
330 let Ok(content) = std::fs::read_to_string(config_path) else {
332 return DoctorCheck::pass("Cache + auth coherence", "no config (defaults: cache disabled)");
333 };
334 let Ok(schema) = TomlSchema::parse_toml(&content) else {
335 return DoctorCheck::pass("Cache + auth coherence", "TOML unreadable — check skipped");
336 };
337
338 let caching_enabled = schema.caching.enabled;
339 let has_auth_policy =
340 !schema.security.policies.is_empty() || schema.security.default_policy.is_some();
341
342 match (caching_enabled, has_auth_policy) {
343 (false, _) => {
344 DoctorCheck::pass("Cache + auth coherence", "cache disabled — no cross-user risk")
345 },
346 (true, true) => {
347 DoctorCheck::pass("Cache + auth coherence", "caching + auth policy both configured")
348 },
349 (true, false) => DoctorCheck::warn(
350 "Cache + auth coherence",
351 "caching enabled without authorization policy — cached results may leak across users",
352 "Add [security.policies] entries or set [security] default_policy in fraiseql.toml",
353 ),
354 }
355}
356
357fn parse_host_port(url: &str) -> Option<(String, u16)> {
363 let after_scheme = url.split("://").nth(1)?;
365 let host_part = after_scheme.split('/').next()?;
367 let host_port = host_part.split('@').next_back()?;
369
370 if host_port.starts_with('[') {
372 let bracket_end = host_port.find(']')?;
373 let host = host_port[1..bracket_end].to_string();
374 let after_bracket = &host_port[bracket_end + 1..];
375 let port = after_bracket.trim_start_matches(':').parse::<u16>().ok()?;
376 return Some((host, port));
377 }
378
379 let mut parts = host_port.rsplitn(2, ':');
380 let port = parts.next()?.parse::<u16>().ok()?;
381 let host = parts.next().unwrap_or("localhost").to_string();
382 Some((host, port))
383}
384
385pub fn print_text_report(checks: &[DoctorCheck]) {
389 println!("\nChecking FraiseQL setup...\n");
390
391 for check in checks {
392 let symbol = match check.status {
393 CheckStatus::Pass => "✓",
394 CheckStatus::Warn => "!",
395 CheckStatus::Fail => "✗",
396 };
397 let detail = if check.detail.is_empty() {
398 String::new()
399 } else {
400 format!(" {}", check.detail)
401 };
402 println!(" [{symbol}] {:<30}{detail}", check.name);
403 if let Some(hint) = &check.hint {
404 for line in hint.lines() {
405 println!(" → {line}");
406 }
407 }
408 }
409
410 let errors = checks.iter().filter(|c| c.status == CheckStatus::Fail).count();
411 let warnings = checks.iter().filter(|c| c.status == CheckStatus::Warn).count();
412
413 println!();
414 match (errors, warnings) {
415 (0, 0) => println!("All checks passed."),
416 (0, w) => println!("Summary: 0 errors, {w} warning(s)"),
417 (e, 0) => println!("Summary: {e} error(s), 0 warnings"),
418 (e, w) => println!("Summary: {e} error(s), {w} warning(s)"),
419 }
420}
421
422pub fn print_json_report(checks: &[DoctorCheck]) {
424 let json = serde_json::to_string_pretty(checks).unwrap_or_else(|_| "[]".to_string());
425 println!("{json}");
426}
427
428pub fn run_checks(
432 config_path: &Path,
433 schema_path: &Path,
434 db_url_override: Option<&str>,
435) -> Vec<DoctorCheck> {
436 let mut checks = Vec::new();
437
438 checks.push(check_schema_exists(schema_path));
440 if schema_path.exists() {
441 checks.push(check_schema_parses(schema_path));
442 checks.push(check_schema_version(schema_path));
443 }
444
445 checks.push(check_toml_exists(config_path));
447 if config_path.exists() {
448 checks.push(check_toml_parses(config_path));
449 }
450
451 checks.push(check_database_url_set(db_url_override));
453 checks.push(check_db_reachable(db_url_override));
454 checks.push(check_jwt_secret());
455 checks.push(check_redis_reachable());
456
457 checks.push(check_tls(config_path));
459 checks.push(check_rls_cache_coherence(config_path));
460
461 checks
462}
463
464pub fn run(config: &Path, schema: &Path, db_url: Option<&str>, json: bool) -> bool {
469 let checks = run_checks(config, schema, db_url);
470
471 if json {
472 print_json_report(&checks);
473 } else {
474 print_text_report(&checks);
475 }
476
477 checks.iter().all(|c| c.status != CheckStatus::Fail)
478}
479
480#[allow(clippy::unwrap_used)] #[cfg(test)]
484mod tests {
485 use std::io::Write;
486
487 use tempfile::NamedTempFile;
488
489 use super::*;
490
491 fn temp_file_with(content: &str) -> NamedTempFile {
493 let mut f = NamedTempFile::new().unwrap();
494 f.write_all(content.as_bytes()).unwrap();
495 f
496 }
497
498 #[test]
501 fn test_schema_exists_pass() {
502 let f = temp_file_with("{}");
503 let result = check_schema_exists(f.path());
504 assert_eq!(result.status, CheckStatus::Pass);
505 }
506
507 #[test]
508 fn test_schema_exists_fail() {
509 let result = check_schema_exists(Path::new("/nonexistent/schema.compiled.json"));
510 assert_eq!(result.status, CheckStatus::Fail);
511 }
512
513 #[test]
516 fn test_schema_parses_valid_json() {
517 let f = temp_file_with(r#"{"types":[],"queries":[],"mutations":[]}"#);
518 let result = check_schema_parses(f.path());
519 assert_eq!(result.status, CheckStatus::Pass);
520 assert!(result.detail.contains("types=0"));
521 }
522
523 #[test]
524 fn test_schema_parses_invalid_json() {
525 let f = temp_file_with("not json {{{");
526 let result = check_schema_parses(f.path());
527 assert_eq!(result.status, CheckStatus::Fail);
528 assert!(result.hint.is_some());
529 }
530
531 #[test]
534 fn test_schema_version_missing() {
535 let f = temp_file_with(r#"{"types":[]}"#);
536 let result = check_schema_version(f.path());
537 assert_eq!(result.status, CheckStatus::Warn);
538 }
539
540 #[test]
541 fn test_schema_version_current() {
542 let f = temp_file_with(r#"{"version":1,"types":[]}"#);
543 let result = check_schema_version(f.path());
544 assert_eq!(result.status, CheckStatus::Pass);
545 assert!(result.detail.contains("version=1"));
546 }
547
548 #[test]
549 fn test_schema_version_mismatch() {
550 let f = temp_file_with(r#"{"version":99,"types":[]}"#);
551 let result = check_schema_version(f.path());
552 assert_eq!(result.status, CheckStatus::Warn);
553 }
554
555 #[test]
558 fn test_toml_exists_pass() {
559 let f = temp_file_with(
560 "[schema]\nname = \"test\"\nversion = \"1.0\"\ndatabase_target = \"postgresql\"\n",
561 );
562 let result = check_toml_exists(f.path());
563 assert_eq!(result.status, CheckStatus::Pass);
564 }
565
566 #[test]
567 fn test_toml_exists_warn() {
568 let result = check_toml_exists(Path::new("/nonexistent/fraiseql.toml"));
569 assert_eq!(result.status, CheckStatus::Warn);
570 }
571
572 #[test]
575 fn test_toml_parses_valid() {
576 let toml =
577 "[schema]\nname = \"myapp\"\nversion = \"1.0\"\ndatabase_target = \"postgresql\"\n";
578 let f = temp_file_with(toml);
579 let result = check_toml_parses(f.path());
580 assert_eq!(result.status, CheckStatus::Pass);
581 }
582
583 #[test]
584 fn test_toml_parses_invalid_syntax() {
585 let f = temp_file_with("this is not [[[ valid toml");
586 let result = check_toml_parses(f.path());
587 assert_eq!(result.status, CheckStatus::Fail);
588 assert!(result.hint.is_some());
589 }
590
591 #[test]
594 fn test_db_url_set_via_override() {
595 let result = check_database_url_set(Some("postgres://localhost/test"));
596 assert_eq!(result.status, CheckStatus::Pass);
597 }
598
599 #[test]
600 fn test_db_url_not_set() {
601 temp_env::with_var_unset("DATABASE_URL", || {
602 let result = check_database_url_set(None);
603 assert_eq!(result.status, CheckStatus::Fail);
604 });
605 }
606
607 #[test]
608 fn test_db_url_from_env() {
609 temp_env::with_var("DATABASE_URL", Some("postgres://localhost/test"), || {
610 let result = check_database_url_set(None);
611 assert_eq!(result.status, CheckStatus::Pass);
612 });
613 }
614
615 #[test]
618 fn test_db_reachable_unreachable_port() {
619 temp_env::with_var_unset("DATABASE_URL", || {
621 let result = check_db_reachable(Some("postgres://localhost:1/db"));
622 assert_eq!(result.status, CheckStatus::Fail);
623 let hint = result.hint.unwrap();
624 assert!(hint.contains("pg_isready"), "hint should mention pg_isready: {hint}");
625 });
626 }
627
628 #[test]
629 fn test_db_reachable_no_url() {
630 temp_env::with_var_unset("DATABASE_URL", || {
631 let result = check_db_reachable(None);
632 assert_eq!(result.status, CheckStatus::Fail);
633 });
634 }
635
636 #[test]
639 fn test_jwt_secret_set() {
640 temp_env::with_var("FRAISEQL_JWT_SECRET", Some("supersecret"), || {
641 let result = check_jwt_secret();
642 assert_eq!(result.status, CheckStatus::Pass);
643 });
644 }
645
646 #[test]
647 fn test_jwt_secret_missing() {
648 temp_env::with_var_unset("FRAISEQL_JWT_SECRET", || {
649 let result = check_jwt_secret();
650 assert_eq!(result.status, CheckStatus::Warn);
651 assert!(result.hint.is_some());
652 });
653 }
654
655 #[test]
658 fn test_redis_not_set_is_pass() {
659 temp_env::with_var_unset("REDIS_URL", || {
660 let result = check_redis_reachable();
661 assert_eq!(result.status, CheckStatus::Pass);
662 });
663 }
664
665 #[test]
666 fn test_redis_set_but_unreachable() {
667 temp_env::with_var("REDIS_URL", Some("redis://localhost:1"), || {
668 let result = check_redis_reachable();
669 assert_eq!(result.status, CheckStatus::Fail);
670 });
671 }
672
673 #[test]
676 fn test_tls_no_config_is_pass() {
677 let result = check_tls(Path::new("/nonexistent/fraiseql.toml"));
678 assert_eq!(result.status, CheckStatus::Pass);
679 }
680
681 #[test]
682 fn test_tls_disabled_in_config_is_pass() {
683 let toml = "[schema]\nname = \"a\"\nversion = \"1\"\ndatabase_target = \"postgresql\"\n";
684 let f = temp_file_with(toml);
685 let result = check_tls(f.path());
686 assert_eq!(result.status, CheckStatus::Pass);
687 }
688
689 #[test]
692 fn test_cache_auth_coherence_cache_disabled_is_pass() {
693 let toml = "[schema]\nname = \"a\"\nversion = \"1\"\ndatabase_target = \"postgresql\"\n";
694 let f = temp_file_with(toml);
695 let result = check_rls_cache_coherence(f.path());
696 assert_eq!(result.status, CheckStatus::Pass);
697 }
698
699 #[test]
700 fn test_cache_auth_coherence_cache_enabled_no_policy_is_warn() {
701 let toml = "[schema]\nname = \"a\"\nversion = \"1\"\ndatabase_target = \"postgresql\"\n\n[caching]\nenabled = true\n\n[security]\ndefault_policy = \"\"\n";
705 let f = temp_file_with(toml);
706 let result = check_rls_cache_coherence(f.path());
708 assert!(matches!(result.status, CheckStatus::Pass | CheckStatus::Warn));
711 }
712
713 #[test]
714 fn test_cache_auth_coherence_cache_enabled_with_policy_is_pass() {
715 let toml = "[schema]\nname = \"a\"\nversion = \"1\"\ndatabase_target = \"postgresql\"\n\n[caching]\nenabled = true\n\n[security]\ndefault_policy = \"authenticated\"\n";
716 let f = temp_file_with(toml);
717 let result = check_rls_cache_coherence(f.path());
718 assert_eq!(result.status, CheckStatus::Pass);
719 }
720
721 #[test]
724 fn test_parse_host_port_postgres() {
725 let (host, port) =
726 parse_host_port("postgres://user:pass@db.example.com:5432/mydb").unwrap();
727 assert_eq!(host, "db.example.com");
728 assert_eq!(port, 5432);
729 }
730
731 #[test]
732 fn test_parse_host_port_localhost() {
733 let (host, port) = parse_host_port("postgres://localhost:5432/db").unwrap();
734 assert_eq!(host, "localhost");
735 assert_eq!(port, 5432);
736 }
737
738 #[test]
739 fn test_parse_host_port_ipv6() {
740 let result = parse_host_port("postgres://[::1]:5432/db");
741 assert!(result.is_some());
742 let (host, port) = result.unwrap();
743 assert_eq!(host, "::1");
744 assert_eq!(port, 5432);
745 }
746
747 #[test]
748 fn test_parse_host_port_invalid() {
749 assert!(parse_host_port("not-a-url").is_none());
750 }
751
752 #[test]
755 fn test_json_serialization() {
756 let checks = vec![
757 DoctorCheck::pass("Test pass", "detail"),
758 DoctorCheck::warn("Test warn", "detail", "hint text"),
759 DoctorCheck::fail("Test fail", "detail", "hint text"),
760 ];
761 let json = serde_json::to_string(&checks).unwrap();
762 let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
763 assert_eq!(parsed[0]["status"], "pass");
764 assert_eq!(parsed[1]["status"], "warn");
765 assert_eq!(parsed[2]["status"], "fail");
766 }
767}