1use std::{collections::HashSet, env};
9
10use fraiseql_error::ConfigError;
11
12use crate::config::RuntimeConfig;
13
14pub struct ValidationResult {
16 pub errors: Vec<ConfigError>,
18 pub warnings: Vec<String>,
20}
21
22impl ValidationResult {
23 pub const fn new() -> Self {
25 Self {
26 errors: Vec::new(),
27 warnings: Vec::new(),
28 }
29 }
30
31 pub const fn is_ok(&self) -> bool {
33 self.errors.is_empty()
34 }
35
36 pub const fn is_err(&self) -> bool {
38 !self.errors.is_empty()
39 }
40
41 pub fn add_error(&mut self, error: ConfigError) {
43 self.errors.push(error);
44 }
45
46 pub fn add_warning(&mut self, warning: impl Into<String>) {
48 self.warnings.push(warning.into());
49 }
50
51 pub fn into_result(self) -> Result<Vec<String>, ConfigError> {
63 if self.errors.is_empty() {
64 Ok(self.warnings)
65 } else if self.errors.len() == 1 {
66 Err(self.errors.into_iter().next().expect("errors.len() == 1 confirmed above"))
67 } else {
68 Err(ConfigError::MultipleErrors {
69 errors: self.errors,
70 })
71 }
72 }
73}
74
75impl Default for ValidationResult {
76 fn default() -> Self {
77 Self::new()
78 }
79}
80
81pub struct ConfigValidator<'a> {
83 config: &'a RuntimeConfig,
84 result: ValidationResult,
85 checked_env_vars: HashSet<String>,
86}
87
88impl<'a> ConfigValidator<'a> {
89 pub fn new(config: &'a RuntimeConfig) -> Self {
91 Self {
92 config,
93 result: ValidationResult::new(),
94 checked_env_vars: HashSet::new(),
95 }
96 }
97
98 pub fn validate(mut self) -> ValidationResult {
100 self.validate_server();
101 self.validate_database();
102 self.validate_webhooks();
103 self.validate_auth();
104 self.validate_files();
105 self.validate_cross_field();
106 self.validate_env_vars();
107 self.validate_placeholder_sections();
108 self.result
109 }
110
111 fn validate_placeholder_sections(&mut self) {
117 if self.config.notifications.is_some() {
118 self.result.add_error(ConfigError::ValidationError {
119 field: "notifications".to_string(),
120 message: "config section 'notifications' is not yet implemented; \
121 remove it from fraiseql.toml to proceed"
122 .to_string(),
123 });
124 }
125 if self.config.logging.is_some() {
126 self.result.add_error(ConfigError::ValidationError {
127 field: "logging".to_string(),
128 message: "config section 'logging' is not yet implemented; \
129 use the 'tracing' section for observability"
130 .to_string(),
131 });
132 }
133 if self.config.search.is_some() {
134 self.result.add_error(ConfigError::ValidationError {
135 field: "search".to_string(),
136 message: "config section 'search' is not yet implemented; \
137 remove it from fraiseql.toml to proceed"
138 .to_string(),
139 });
140 }
141 if self.config.cache.is_some() {
142 self.result.add_error(ConfigError::ValidationError {
143 field: "cache".to_string(),
144 message: "config section 'cache' is not yet implemented; \
145 use fraiseql_core::cache::CacheConfig for query-result caching"
146 .to_string(),
147 });
148 }
149 if self.config.queues.is_some() {
150 self.result.add_error(ConfigError::ValidationError {
151 field: "queues".to_string(),
152 message: "config section 'queues' is not yet implemented; \
153 remove it from fraiseql.toml to proceed"
154 .to_string(),
155 });
156 }
157 if self.config.realtime.is_some() {
158 self.result.add_error(ConfigError::ValidationError {
159 field: "realtime".to_string(),
160 message: "config section 'realtime' is not yet implemented; \
161 use the 'subscriptions' feature for real-time updates"
162 .to_string(),
163 });
164 }
165 if self.config.custom_endpoints.is_some() {
166 self.result.add_error(ConfigError::ValidationError {
167 field: "custom_endpoints".to_string(),
168 message: "config section 'custom_endpoints' is not yet implemented; \
169 remove it from fraiseql.toml to proceed"
170 .to_string(),
171 });
172 }
173 }
174
175 fn validate_server(&mut self) {
176 if self.config.server.port == 0 {
178 self.result.add_error(ConfigError::ValidationError {
179 field: "server.port".to_string(),
180 message: "Port cannot be 0".to_string(),
181 });
182 }
183
184 if let Some(limits) = &self.config.server.limits {
186 if let Err(e) = crate::config::env::parse_size(&limits.max_request_size) {
187 self.result.add_error(ConfigError::ValidationError {
188 field: "server.limits.max_request_size".to_string(),
189 message: format!("Invalid size format: {}", e),
190 });
191 }
192
193 if let Err(e) = crate::config::env::parse_duration(&limits.request_timeout) {
194 self.result.add_error(ConfigError::ValidationError {
195 field: "server.limits.request_timeout".to_string(),
196 message: format!("Invalid duration format: {}", e),
197 });
198 }
199
200 if limits.max_concurrent_requests == 0 {
201 self.result.add_error(ConfigError::ValidationError {
202 field: "server.limits.max_concurrent_requests".to_string(),
203 message: "Must be greater than 0".to_string(),
204 });
205 }
206 }
207
208 if let Some(tls) = &self.config.server.tls {
210 if !tls.cert_file.exists() {
211 self.result.add_error(ConfigError::ValidationError {
212 field: "server.tls.cert_file".to_string(),
213 message: format!("Certificate file not found: {}", tls.cert_file.display()),
214 });
215 }
216 if !tls.key_file.exists() {
217 self.result.add_error(ConfigError::ValidationError {
218 field: "server.tls.key_file".to_string(),
219 message: format!("Key file not found: {}", tls.key_file.display()),
220 });
221 }
222 }
223 }
224
225 fn validate_database(&mut self) {
226 if self.config.database.url_env.is_empty() {
228 self.result.add_error(ConfigError::ValidationError {
229 field: "database.url_env".to_string(),
230 message: "Database URL environment variable must be specified".to_string(),
231 });
232 } else {
233 self.checked_env_vars.insert(self.config.database.url_env.clone());
234 }
235
236 if self.config.database.pool_size == 0 {
238 self.result.add_error(ConfigError::ValidationError {
239 field: "database.pool_size".to_string(),
240 message: "Pool size must be greater than 0".to_string(),
241 });
242 }
243
244 for (i, replica) in self.config.database.replicas.iter().enumerate() {
246 if replica.url_env.is_empty() {
247 self.result.add_error(ConfigError::ValidationError {
248 field: format!("database.replicas[{}].url_env", i),
249 message: "Replica URL environment variable must be specified".to_string(),
250 });
251 } else {
252 self.checked_env_vars.insert(replica.url_env.clone());
253 }
254 }
255 }
256
257 fn validate_webhooks(&mut self) {
258 for (name, webhook) in &self.config.webhooks {
259 if webhook.secret_env.is_empty() {
261 self.result.add_error(ConfigError::ValidationError {
262 field: format!("webhooks.{}.secret_env", name),
263 message: "Webhook secret environment variable must be specified".to_string(),
264 });
265 } else {
266 self.checked_env_vars.insert(webhook.secret_env.clone());
267 }
268
269 let valid_providers = [
271 "stripe",
272 "github",
273 "shopify",
274 "twilio",
275 "sendgrid",
276 "paddle",
277 "slack",
278 "discord",
279 "linear",
280 "svix",
281 "clerk",
282 "supabase",
283 "novu",
284 "resend",
285 "generic_hmac",
286 ];
287 if !valid_providers.contains(&webhook.provider.as_str()) {
288 self.result.add_warning(format!(
289 "Unknown webhook provider '{}' for webhook '{}'. Using generic_hmac.",
290 webhook.provider, name
291 ));
292 }
293 }
294 }
295
296 fn validate_auth(&mut self) {
297 if let Some(auth) = &self.config.auth {
298 if auth.jwt.secret_env.is_empty() {
300 self.result.add_error(ConfigError::ValidationError {
301 field: "auth.jwt.secret_env".to_string(),
302 message: "JWT secret environment variable must be specified".to_string(),
303 });
304 } else {
305 self.checked_env_vars.insert(auth.jwt.secret_env.clone());
306 }
307
308 for (name, provider) in &auth.providers {
310 self.checked_env_vars.insert(provider.client_id_env.clone());
311 self.checked_env_vars.insert(provider.client_secret_env.clone());
312
313 if provider.provider_type == "oidc" && provider.issuer_url.is_none() {
315 self.result.add_error(ConfigError::ValidationError {
316 field: format!("auth.providers.{}.issuer_url", name),
317 message: "OIDC providers require issuer_url".to_string(),
318 });
319 }
320 }
321
322 if !auth.providers.is_empty() && auth.callback_base_url.is_none() {
324 self.result.add_error(ConfigError::ValidationError {
325 field: "auth.callback_base_url".to_string(),
326 message: "callback_base_url is required when OAuth providers are configured"
327 .to_string(),
328 });
329 }
330 }
331 }
332
333 fn validate_files(&mut self) {
334 for (name, file_config) in &self.config.files {
335 if !self.config.storage.contains_key(&file_config.storage) {
337 self.result.add_error(ConfigError::ValidationError {
338 field: format!("files.{}.storage", name),
339 message: format!(
340 "Storage backend '{}' not found in storage configuration",
341 file_config.storage
342 ),
343 });
344 }
345
346 if let Err(e) = crate::config::env::parse_size(&file_config.max_size) {
348 self.result.add_error(ConfigError::ValidationError {
349 field: format!("files.{}.max_size", name),
350 message: format!("Invalid size format: {}", e),
351 });
352 }
353 }
354
355 for (name, storage) in &self.config.storage {
357 match storage.backend.as_str() {
358 "s3" | "r2" | "gcs" => {
359 if storage.bucket.is_none() {
360 self.result.add_error(ConfigError::ValidationError {
361 field: format!("storage.{}.bucket", name),
362 message: "Bucket name is required for cloud storage".to_string(),
363 });
364 }
365 },
366 "local" => {
367 if storage.path.is_none() {
368 self.result.add_error(ConfigError::ValidationError {
369 field: format!("storage.{}.path", name),
370 message: "Path is required for local storage".to_string(),
371 });
372 }
373 },
374 _ => {
375 self.result.add_error(ConfigError::ValidationError {
376 field: format!("storage.{}.backend", name),
377 message: format!("Unknown storage backend: {}", storage.backend),
378 });
379 },
380 }
381 }
382 }
383
384 fn validate_cross_field(&mut self) {
385 for (name, observer) in &self.config.observers {
387 for action in &observer.actions {
388 match action.action_type.as_str() {
389 "email" | "slack" | "sms" | "push" => {
390 if self.config.notifications.is_none() {
391 self.result.add_error(ConfigError::ValidationError {
392 field: format!("observers.{}.actions", name),
393 message: format!(
394 "Observer '{}' uses '{}' action but notifications are not configured",
395 name, action.action_type
396 ),
397 });
398 }
399 },
400 _ => {},
401 }
402 }
403 }
404
405 if let Some(rate_limit) = &self.config.rate_limiting {
407 if rate_limit.backend == "redis" && self.config.cache.is_none() {
408 self.result.add_error(ConfigError::ValidationError {
409 field: "rate_limiting.backend".to_string(),
410 message: "Redis rate limiting requires cache configuration. \
411 Add a [cache] section to fraiseql.toml or change \
412 [rate_limiting] backend from 'redis' to 'memory'."
413 .to_string(),
414 });
415 }
416 }
417 }
418
419 fn validate_env_vars(&mut self) {
420 for var_name in &self.checked_env_vars {
422 if env::var(var_name).is_err() {
423 self.result.add_error(ConfigError::MissingEnvVar {
424 name: var_name.clone(),
425 });
426 }
427 }
428 }
429}
430
431#[cfg(test)]
432mod tests {
433 #![allow(clippy::unwrap_used)] use super::*;
436
437 #[test]
440 fn empty_result_is_ok() {
441 let result = ValidationResult::new();
442 assert!(result.is_ok());
443 assert!(!result.is_err());
444 }
445
446 #[test]
447 fn result_with_error_is_err() {
448 let mut result = ValidationResult::new();
449 result.add_error(ConfigError::ValidationError {
450 field: "test".into(),
451 message: "bad".into(),
452 });
453 assert!(result.is_err());
454 assert!(!result.is_ok());
455 }
456
457 #[test]
458 fn result_with_only_warnings_is_ok() {
459 let mut result = ValidationResult::new();
460 result.add_warning("heads up");
461 assert!(result.is_ok());
462 }
463
464 #[test]
465 fn into_result_single_error() {
466 let mut result = ValidationResult::new();
467 result.add_error(ConfigError::ValidationError {
468 field: "port".into(),
469 message: "invalid".into(),
470 });
471 let err = result.into_result().unwrap_err();
472 assert!(
473 matches!(err, ConfigError::ValidationError { ref field, .. } if field == "port"),
474 "single error must be unwrapped, not wrapped in MultipleErrors"
475 );
476 }
477
478 #[test]
479 fn into_result_multiple_errors() {
480 let mut result = ValidationResult::new();
481 result.add_error(ConfigError::ValidationError {
482 field: "a".into(),
483 message: "bad a".into(),
484 });
485 result.add_error(ConfigError::ValidationError {
486 field: "b".into(),
487 message: "bad b".into(),
488 });
489 let err = result.into_result().unwrap_err();
490 assert!(
491 matches!(err, ConfigError::MultipleErrors { ref errors } if errors.len() == 2),
492 "multiple errors must be wrapped in MultipleErrors"
493 );
494 }
495
496 #[test]
497 fn into_result_ok_returns_warnings() {
498 let mut result = ValidationResult::new();
499 result.add_warning("warn1");
500 result.add_warning("warn2");
501 let warnings = result.into_result().unwrap();
502 assert_eq!(warnings.len(), 2);
503 }
504
505 fn minimal_config(toml_override: &str) -> RuntimeConfig {
509 let toml = format!(
510 r#"
511 [server]
512 port = 4000
513
514 [database]
515 url_env = "DATABASE_URL"
516
517 {toml_override}
518 "#
519 );
520 toml::from_str(&toml).unwrap()
521 }
522
523 #[test]
524 fn valid_minimal_config_passes_validation() {
525 temp_env::with_var("DATABASE_URL", Some("postgres://localhost/test"), || {
526 let config = minimal_config("");
527 let result = ConfigValidator::new(&config).validate();
528 assert!(result.is_ok(), "valid minimal config must pass: {:?}", result.errors);
529 });
530 }
531
532 #[test]
533 fn port_zero_fails_validation() {
534 temp_env::with_var("DATABASE_URL", Some("postgres://localhost/test"), || {
535 let toml = r#"
536 [server]
537 port = 0
538
539 [database]
540 url_env = "DATABASE_URL"
541 "#;
542 let config: RuntimeConfig = toml::from_str(toml).unwrap();
543 let result = ConfigValidator::new(&config).validate();
544 assert!(result.is_err(), "port=0 must fail validation");
545 assert!(
546 result.errors.iter().any(|e| {
547 matches!(e, ConfigError::ValidationError { ref field, .. } if field.contains("port"))
548 }),
549 "error must reference port field"
550 );
551 });
552 }
553
554 #[test]
555 fn pool_size_zero_fails_validation() {
556 temp_env::with_var("DATABASE_URL", Some("postgres://localhost/test"), || {
557 let toml = r#"
558 [server]
559 port = 4000
560
561 [database]
562 url_env = "DATABASE_URL"
563 pool_size = 0
564 "#;
565 let config: RuntimeConfig = toml::from_str(toml).unwrap();
566 let result = ConfigValidator::new(&config).validate();
567 assert!(result.is_err(), "pool_size=0 must fail validation");
568 assert!(
569 result.errors.iter().any(|e| {
570 matches!(e, ConfigError::ValidationError { ref field, .. } if field.contains("pool_size"))
571 }),
572 "error must reference pool_size field"
573 );
574 });
575 }
576
577 #[test]
578 fn empty_database_url_env_fails_validation() {
579 let toml = r#"
580 [server]
581 port = 4000
582
583 [database]
584 url_env = ""
585 "#;
586 let config: RuntimeConfig = toml::from_str(toml).unwrap();
587 let result = ConfigValidator::new(&config).validate();
588 assert!(result.is_err(), "empty url_env must fail validation");
589 }
590
591 #[test]
592 fn placeholder_section_notifications_fails() {
593 temp_env::with_var("DATABASE_URL", Some("postgres://localhost/test"), || {
594 let toml = r#"
595 [server]
596 port = 4000
597
598 [database]
599 url_env = "DATABASE_URL"
600
601 [notifications]
602 enabled = true
603 "#;
604 let config: RuntimeConfig = toml::from_str(toml).unwrap();
605 let result = ConfigValidator::new(&config).validate();
606 assert!(
607 result.errors.iter().any(|e| {
608 matches!(e, ConfigError::ValidationError { ref field, .. } if field == "notifications")
609 }),
610 "placeholder 'notifications' section must be rejected"
611 );
612 });
613 }
614
615 #[test]
616 fn placeholder_section_logging_fails() {
617 temp_env::with_var("DATABASE_URL", Some("postgres://localhost/test"), || {
618 let toml = r#"
619 [server]
620 port = 4000
621
622 [database]
623 url_env = "DATABASE_URL"
624
625 [logging]
626 level = "debug"
627 "#;
628 let config: RuntimeConfig = toml::from_str(toml).unwrap();
629 let result = ConfigValidator::new(&config).validate();
630 assert!(
631 result.errors.iter().any(|e| {
632 matches!(e, ConfigError::ValidationError { ref field, .. } if field == "logging")
633 }),
634 "placeholder 'logging' section must be rejected"
635 );
636 });
637 }
638
639 #[test]
640 fn invalid_max_request_size_fails_validation() {
641 temp_env::with_var("DATABASE_URL", Some("postgres://localhost/test"), || {
642 let toml = r#"
643 [server]
644 port = 4000
645
646 [server.limits]
647 max_request_size = "not-a-size"
648
649 [database]
650 url_env = "DATABASE_URL"
651 "#;
652 let config: RuntimeConfig = toml::from_str(toml).unwrap();
653 let result = ConfigValidator::new(&config).validate();
654 assert!(
655 result.errors.iter().any(|e| {
656 matches!(e, ConfigError::ValidationError { ref field, .. }
657 if field.contains("max_request_size"))
658 }),
659 "invalid max_request_size must fail validation"
660 );
661 });
662 }
663
664 #[test]
665 fn zero_max_concurrent_requests_fails_validation() {
666 temp_env::with_var("DATABASE_URL", Some("postgres://localhost/test"), || {
667 let toml = r#"
668 [server]
669 port = 4000
670
671 [server.limits]
672 max_concurrent_requests = 0
673
674 [database]
675 url_env = "DATABASE_URL"
676 "#;
677 let config: RuntimeConfig = toml::from_str(toml).unwrap();
678 let result = ConfigValidator::new(&config).validate();
679 assert!(
680 result.errors.iter().any(|e| {
681 matches!(e, ConfigError::ValidationError { ref field, .. }
682 if field.contains("max_concurrent_requests"))
683 }),
684 "max_concurrent_requests=0 must fail validation"
685 );
686 });
687 }
688
689 #[test]
690 fn redis_rate_limiting_without_cache_error_references_fraiseql_toml() {
691 temp_env::with_var("DATABASE_URL", Some("postgres://localhost/test"), || {
692 let toml = r#"
693 [server]
694 port = 4000
695
696 [database]
697 url_env = "DATABASE_URL"
698
699 [rate_limiting]
700 default = "100/minute"
701 backend = "redis"
702 "#;
703 let config: RuntimeConfig = toml::from_str(toml).unwrap();
704 let result = ConfigValidator::new(&config).validate();
705 let has_toml_ref = result.errors.iter().any(|e| {
706 matches!(e, ConfigError::ValidationError { ref message, .. }
707 if message.contains("fraiseql.toml"))
708 });
709 assert!(
710 has_toml_ref,
711 "error message must reference fraiseql.toml; errors: {:?}",
712 result.errors
713 );
714 });
715 }
716
717 #[test]
718 fn multiple_errors_collected_in_one_pass() {
719 let toml = r#"
720 [server]
721 port = 0
722
723 [database]
724 url_env = ""
725 pool_size = 0
726 "#;
727 let config: RuntimeConfig = toml::from_str(toml).unwrap();
728 let result = ConfigValidator::new(&config).validate();
729 assert!(
730 result.errors.len() >= 3,
731 "validator must collect all errors in one pass, got {} errors: {:?}",
732 result.errors.len(),
733 result.errors
734 );
735 }
736}