1pub mod cluster;
2mod database;
3pub mod signals;
4
5pub use cluster::ClusterConfig;
6pub use database::{DatabaseConfig, PoolConfig};
7pub use signals::SignalsConfig;
8
9use serde::{Deserialize, Serialize};
10use std::path::Path;
11
12use crate::error::{ForgeError, Result};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ForgeConfig {
17 #[serde(default)]
19 pub project: ProjectConfig,
20
21 pub database: DatabaseConfig,
23
24 #[serde(default)]
26 pub node: NodeConfig,
27
28 #[serde(default)]
30 pub gateway: GatewayConfig,
31
32 #[serde(default)]
34 pub function: FunctionConfig,
35
36 #[serde(default)]
38 pub worker: WorkerConfig,
39
40 #[serde(default)]
42 pub cluster: ClusterConfig,
43
44 #[serde(default)]
46 pub security: SecurityConfig,
47
48 #[serde(default)]
50 pub auth: AuthConfig,
51
52 #[serde(default)]
54 pub observability: ObservabilityConfig,
55
56 #[serde(default)]
58 pub mcp: McpConfig,
59
60 #[serde(default)]
62 pub signals: SignalsConfig,
63}
64
65impl ForgeConfig {
66 pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
68 let content = std::fs::read_to_string(path.as_ref())
69 .map_err(|e| ForgeError::Config(format!("Failed to read config file: {}", e)))?;
70
71 Self::parse_toml(&content)
72 }
73
74 pub fn parse_toml(content: &str) -> Result<Self> {
76 let content = substitute_env_vars(content);
78
79 let config: Self = toml::from_str(&content)
80 .map_err(|e| ForgeError::Config(format!("Failed to parse config: {}", e)))?;
81
82 config.validate()?;
83 Ok(config)
84 }
85
86 pub fn validate(&self) -> Result<()> {
88 self.database.validate()?;
89 self.auth.validate()?;
90 self.mcp.validate()?;
91 self.gateway.max_body_size_bytes()?;
92
93 if self.mcp.oauth && self.auth.jwt_secret.is_none() {
95 return Err(ForgeError::Config(
96 "mcp.oauth = true requires auth.jwt_secret to be set. \
97 OAuth-issued tokens are signed with this secret, even when using \
98 an external provider (JWKS) for identity verification."
99 .into(),
100 ));
101 }
102 if self.mcp.oauth && !self.mcp.enabled {
103 return Err(ForgeError::Config(
104 "mcp.oauth = true requires mcp.enabled = true".into(),
105 ));
106 }
107
108 Ok(())
109 }
110
111 pub fn default_with_database_url(url: &str) -> Self {
113 Self {
114 project: ProjectConfig::default(),
115 database: DatabaseConfig::new(url),
116 node: NodeConfig::default(),
117 gateway: GatewayConfig::default(),
118 function: FunctionConfig::default(),
119 worker: WorkerConfig::default(),
120 cluster: ClusterConfig::default(),
121 security: SecurityConfig::default(),
122 auth: AuthConfig::default(),
123 observability: ObservabilityConfig::default(),
124 mcp: McpConfig::default(),
125 signals: SignalsConfig::default(),
126 }
127 }
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct ProjectConfig {
133 #[serde(default = "default_project_name")]
135 pub name: String,
136
137 #[serde(default = "default_version")]
139 pub version: String,
140}
141
142impl Default for ProjectConfig {
143 fn default() -> Self {
144 Self {
145 name: default_project_name(),
146 version: default_version(),
147 }
148 }
149}
150
151fn default_project_name() -> String {
152 "forge-app".to_string()
153}
154
155fn default_version() -> String {
156 "0.1.0".to_string()
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct NodeConfig {
162 #[serde(default = "default_roles")]
164 pub roles: Vec<NodeRole>,
165
166 #[serde(default = "default_capabilities")]
168 pub worker_capabilities: Vec<String>,
169}
170
171impl Default for NodeConfig {
172 fn default() -> Self {
173 Self {
174 roles: default_roles(),
175 worker_capabilities: default_capabilities(),
176 }
177 }
178}
179
180fn default_roles() -> Vec<NodeRole> {
181 vec![
182 NodeRole::Gateway,
183 NodeRole::Function,
184 NodeRole::Worker,
185 NodeRole::Scheduler,
186 ]
187}
188
189fn default_capabilities() -> Vec<String> {
190 vec!["general".to_string()]
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "lowercase")]
196pub enum NodeRole {
197 Gateway,
198 Function,
199 Worker,
200 Scheduler,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct GatewayConfig {
206 #[serde(default = "default_http_port")]
208 pub port: u16,
209
210 #[serde(default = "default_grpc_port")]
216 pub grpc_port: u16,
217
218 #[serde(default = "default_max_connections")]
220 pub max_connections: usize,
221
222 #[serde(default = "default_sse_max_sessions")]
224 pub sse_max_sessions: usize,
225
226 #[serde(default = "default_request_timeout")]
228 pub request_timeout_secs: u64,
229
230 #[serde(default = "default_cors_enabled")]
232 pub cors_enabled: bool,
233
234 #[serde(default = "default_cors_origins")]
236 pub cors_origins: Vec<String>,
237
238 #[serde(default = "default_quiet_routes")]
241 pub quiet_routes: Vec<String>,
242
243 #[serde(default = "default_max_body_size")]
245 pub max_body_size: String,
246}
247
248impl Default for GatewayConfig {
249 fn default() -> Self {
250 Self {
251 port: default_http_port(),
252 grpc_port: default_grpc_port(),
253 max_connections: default_max_connections(),
254 sse_max_sessions: default_sse_max_sessions(),
255 request_timeout_secs: default_request_timeout(),
256 cors_enabled: default_cors_enabled(),
257 cors_origins: default_cors_origins(),
258 quiet_routes: default_quiet_routes(),
259 max_body_size: default_max_body_size(),
260 }
261 }
262}
263
264impl GatewayConfig {
265 pub fn max_body_size_bytes(&self) -> crate::Result<usize> {
267 crate::util::parse_size(&self.max_body_size).ok_or_else(|| {
268 crate::ForgeError::Config(format!(
269 "invalid gateway.max_body_size '{}'. Expected a size like '20mb', '1gb', or '1048576'",
270 self.max_body_size
271 ))
272 })
273 }
274}
275
276fn default_http_port() -> u16 {
277 9081
278}
279
280fn default_grpc_port() -> u16 {
281 9000
282}
283
284fn default_max_connections() -> usize {
285 4096
286}
287
288fn default_sse_max_sessions() -> usize {
289 10_000
290}
291
292fn default_request_timeout() -> u64 {
293 30
294}
295
296fn default_cors_enabled() -> bool {
297 false
298}
299
300fn default_cors_origins() -> Vec<String> {
301 Vec::new()
302}
303
304fn default_quiet_routes() -> Vec<String> {
305 vec![
306 "/_api/health".to_string(),
307 "/_api/ready".to_string(),
308 "/_api/signal/event".to_string(),
309 "/_api/signal/view".to_string(),
310 "/_api/signal/user".to_string(),
311 "/_api/signal/report".to_string(),
312 ]
313}
314
315fn default_max_body_size() -> String {
316 "20mb".to_string()
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct FunctionConfig {
322 #[serde(default = "default_max_concurrent")]
324 pub max_concurrent: usize,
325
326 #[serde(default = "default_function_timeout")]
328 pub timeout_secs: u64,
329
330 #[serde(default = "default_memory_limit")]
338 pub memory_limit: usize,
339}
340
341impl Default for FunctionConfig {
342 fn default() -> Self {
343 Self {
344 max_concurrent: default_max_concurrent(),
345 timeout_secs: default_function_timeout(),
346 memory_limit: default_memory_limit(),
347 }
348 }
349}
350
351fn default_max_concurrent() -> usize {
352 1000
353}
354
355fn default_function_timeout() -> u64 {
356 30
357}
358
359fn default_memory_limit() -> usize {
360 512 * 1024 * 1024 }
362
363#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct WorkerConfig {
366 #[serde(default = "default_max_concurrent_jobs")]
368 pub max_concurrent_jobs: usize,
369
370 #[serde(default = "default_job_timeout")]
372 pub job_timeout_secs: u64,
373
374 #[serde(default = "default_poll_interval")]
376 pub poll_interval_ms: u64,
377}
378
379impl Default for WorkerConfig {
380 fn default() -> Self {
381 Self {
382 max_concurrent_jobs: default_max_concurrent_jobs(),
383 job_timeout_secs: default_job_timeout(),
384 poll_interval_ms: default_poll_interval(),
385 }
386 }
387}
388
389fn default_max_concurrent_jobs() -> usize {
390 50
391}
392
393fn default_job_timeout() -> u64 {
394 3600 }
396
397fn default_poll_interval() -> u64 {
398 100
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize, Default)]
403pub struct SecurityConfig {
404 pub secret_key: Option<String>,
406}
407
408#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
410#[serde(rename_all = "UPPERCASE")]
411pub enum JwtAlgorithm {
412 #[default]
414 HS256,
415 HS384,
417 HS512,
419 RS256,
421 RS384,
423 RS512,
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize)]
429pub struct AuthConfig {
430 pub jwt_secret: Option<String>,
433
434 #[serde(default)]
438 pub jwt_algorithm: JwtAlgorithm,
439
440 pub jwt_issuer: Option<String>,
443
444 pub jwt_audience: Option<String>,
447
448 pub access_token_ttl: Option<String>,
451
452 pub refresh_token_ttl: Option<String>,
455
456 pub jwks_url: Option<String>,
459
460 #[serde(default = "default_jwks_cache_ttl")]
462 pub jwks_cache_ttl_secs: u64,
463
464 #[serde(default = "default_session_ttl")]
466 pub session_ttl_secs: u64,
467}
468
469impl Default for AuthConfig {
470 fn default() -> Self {
471 Self {
472 jwt_secret: None,
473 jwt_algorithm: JwtAlgorithm::default(),
474 jwt_issuer: None,
475 jwt_audience: None,
476 access_token_ttl: None,
477 refresh_token_ttl: None,
478 jwks_url: None,
479 jwks_cache_ttl_secs: default_jwks_cache_ttl(),
480 session_ttl_secs: default_session_ttl(),
481 }
482 }
483}
484
485impl AuthConfig {
486 pub fn access_token_ttl_secs(&self) -> i64 {
490 self.access_token_ttl
491 .as_deref()
492 .and_then(crate::util::parse_duration)
493 .map(|d| (d.as_secs() as i64).max(1))
494 .unwrap_or(3600)
495 }
496
497 pub fn refresh_token_ttl_days(&self) -> i64 {
500 self.refresh_token_ttl
501 .as_deref()
502 .and_then(crate::util::parse_duration)
503 .map(|d| (d.as_secs() / 86400) as i64)
504 .map(|d| if d == 0 { 1 } else { d })
505 .unwrap_or(30)
506 }
507
508 fn is_configured(&self) -> bool {
510 self.jwt_secret.is_some()
511 || self.jwks_url.is_some()
512 || self.jwt_issuer.is_some()
513 || self.jwt_audience.is_some()
514 }
515
516 pub fn validate(&self) -> Result<()> {
519 if !self.is_configured() {
520 return Ok(());
521 }
522
523 match self.jwt_algorithm {
524 JwtAlgorithm::HS256 | JwtAlgorithm::HS384 | JwtAlgorithm::HS512 => {
525 if self.jwt_secret.is_none() {
526 return Err(ForgeError::Config(
527 "auth.jwt_secret is required for HMAC algorithms (HS256, HS384, HS512). \
528 Set auth.jwt_secret to a secure random string, \
529 or switch to RS256 and provide auth.jwks_url for external identity providers."
530 .into(),
531 ));
532 }
533 }
534 JwtAlgorithm::RS256 | JwtAlgorithm::RS384 | JwtAlgorithm::RS512 => {
535 if self.jwks_url.is_none() {
536 return Err(ForgeError::Config(
537 "auth.jwks_url is required for RSA algorithms (RS256, RS384, RS512). \
538 Set auth.jwks_url to your identity provider's JWKS endpoint, \
539 or switch to HS256 and provide auth.jwt_secret for symmetric signing."
540 .into(),
541 ));
542 }
543 }
544 }
545 Ok(())
546 }
547
548 pub fn is_hmac(&self) -> bool {
550 matches!(
551 self.jwt_algorithm,
552 JwtAlgorithm::HS256 | JwtAlgorithm::HS384 | JwtAlgorithm::HS512
553 )
554 }
555
556 pub fn is_rsa(&self) -> bool {
558 matches!(
559 self.jwt_algorithm,
560 JwtAlgorithm::RS256 | JwtAlgorithm::RS384 | JwtAlgorithm::RS512
561 )
562 }
563}
564
565fn default_jwks_cache_ttl() -> u64 {
566 3600 }
568
569fn default_session_ttl() -> u64 {
570 7 * 24 * 60 * 60 }
572
573#[derive(Debug, Clone, Serialize, Deserialize)]
575pub struct ObservabilityConfig {
576 #[serde(default)]
578 pub enabled: bool,
579
580 #[serde(default = "default_otlp_endpoint")]
582 pub otlp_endpoint: String,
583
584 pub service_name: Option<String>,
586
587 #[serde(default = "default_true")]
589 pub enable_traces: bool,
590
591 #[serde(default = "default_true")]
593 pub enable_metrics: bool,
594
595 #[serde(default = "default_true")]
597 pub enable_logs: bool,
598
599 #[serde(default = "default_sampling_ratio")]
601 pub sampling_ratio: f64,
602
603 #[serde(default = "default_log_level")]
605 pub log_level: String,
606}
607
608impl Default for ObservabilityConfig {
609 fn default() -> Self {
610 Self {
611 enabled: false,
612 otlp_endpoint: default_otlp_endpoint(),
613 service_name: None,
614 enable_traces: true,
615 enable_metrics: true,
616 enable_logs: true,
617 sampling_ratio: default_sampling_ratio(),
618 log_level: default_log_level(),
619 }
620 }
621}
622
623impl ObservabilityConfig {
624 pub fn otlp_active(&self) -> bool {
625 self.enabled && (self.enable_traces || self.enable_metrics || self.enable_logs)
626 }
627
628 pub fn apply_env_overrides(&mut self) {
635 if let Ok(val) = std::env::var("FORGE_OTEL_TRACES") {
636 self.enable_traces = val.eq_ignore_ascii_case("true") || val == "1";
637 }
638 if let Ok(val) = std::env::var("FORGE_OTEL_METRICS") {
639 self.enable_metrics = val.eq_ignore_ascii_case("true") || val == "1";
640 }
641 if let Ok(val) = std::env::var("FORGE_OTEL_LOGS") {
642 self.enable_logs = val.eq_ignore_ascii_case("true") || val == "1";
643 }
644 }
645}
646
647fn default_otlp_endpoint() -> String {
648 "http://localhost:4318".to_string()
649}
650
651pub(crate) fn default_true() -> bool {
652 true
653}
654
655fn default_sampling_ratio() -> f64 {
656 1.0
657}
658
659fn default_log_level() -> String {
660 "info".to_string()
661}
662
663#[derive(Debug, Clone, Serialize, Deserialize)]
665pub struct McpConfig {
666 #[serde(default)]
668 pub enabled: bool,
669
670 #[serde(default)]
675 pub oauth: bool,
676
677 #[serde(default = "default_mcp_path")]
679 pub path: String,
680
681 #[serde(default = "default_mcp_session_ttl_secs")]
683 pub session_ttl_secs: u64,
684
685 #[serde(default)]
687 pub allowed_origins: Vec<String>,
688
689 #[serde(default = "default_true")]
691 pub require_protocol_version_header: bool,
692}
693
694impl Default for McpConfig {
695 fn default() -> Self {
696 Self {
697 enabled: false,
698 oauth: false,
699 path: default_mcp_path(),
700 session_ttl_secs: default_mcp_session_ttl_secs(),
701 allowed_origins: Vec::new(),
702 require_protocol_version_header: default_true(),
703 }
704 }
705}
706
707impl McpConfig {
708 const RESERVED_PATHS: &[&str] = &[
710 "/health",
711 "/ready",
712 "/rpc",
713 "/events",
714 "/subscribe",
715 "/unsubscribe",
716 "/subscribe-job",
717 "/subscribe-workflow",
718 "/metrics",
719 ];
720
721 pub fn validate(&self) -> Result<()> {
722 if self.path.is_empty() || !self.path.starts_with('/') {
723 return Err(ForgeError::Config(
724 "mcp.path must start with '/' (example: /mcp)".to_string(),
725 ));
726 }
727 if self.path.contains(' ') {
728 return Err(ForgeError::Config(
729 "mcp.path cannot contain spaces".to_string(),
730 ));
731 }
732 if Self::RESERVED_PATHS.contains(&self.path.as_str()) {
733 return Err(ForgeError::Config(format!(
734 "mcp.path '{}' conflicts with a reserved gateway route",
735 self.path
736 )));
737 }
738 if self.session_ttl_secs == 0 {
739 return Err(ForgeError::Config(
740 "mcp.session_ttl_secs must be greater than 0".to_string(),
741 ));
742 }
743 Ok(())
744 }
745}
746
747fn default_mcp_path() -> String {
748 "/mcp".to_string()
749}
750
751fn default_mcp_session_ttl_secs() -> u64 {
752 60 * 60
753}
754
755#[allow(clippy::indexing_slicing)]
762pub fn substitute_env_vars(content: &str) -> String {
763 let mut result = String::with_capacity(content.len());
764 let bytes = content.as_bytes();
765 let len = bytes.len();
766 let mut i = 0;
767
768 while i < len {
769 if i + 1 < len
770 && bytes[i] == b'$'
771 && bytes[i + 1] == b'{'
772 && let Some(end) = content[i + 2..].find('}')
773 {
774 let inner = &content[i + 2..i + 2 + end];
775
776 let (var_name, default_value) = parse_var_with_default(inner);
778
779 if is_valid_env_var_name(var_name) {
780 if let Ok(value) = std::env::var(var_name) {
781 result.push_str(&value);
782 } else if let Some(default) = default_value {
783 result.push_str(default);
784 } else {
785 result.push_str(&content[i..i + 2 + end + 1]);
786 }
787 i += 2 + end + 1;
788 continue;
789 }
790 }
791 result.push(bytes[i] as char);
792 i += 1;
793 }
794
795 result
796}
797
798fn parse_var_with_default(inner: &str) -> (&str, Option<&str>) {
802 if let Some(pos) = inner.find(":-") {
803 return (&inner[..pos], Some(&inner[pos + 2..]));
804 }
805 if let Some(pos) = inner.find('-') {
806 return (&inner[..pos], Some(&inner[pos + 1..]));
807 }
808 (inner, None)
809}
810
811fn is_valid_env_var_name(name: &str) -> bool {
812 let first = match name.as_bytes().first() {
813 Some(b) => b,
814 None => return false,
815 };
816 (first.is_ascii_uppercase() || *first == b'_')
817 && name
818 .bytes()
819 .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
820}
821
822#[cfg(test)]
823#[allow(clippy::unwrap_used, clippy::indexing_slicing, unsafe_code)]
824mod tests {
825 use super::*;
826
827 #[test]
828 fn test_default_config() {
829 let config = ForgeConfig::default_with_database_url("postgres://localhost/test");
830 assert_eq!(config.gateway.port, 9081);
831 assert_eq!(config.node.roles.len(), 4);
832 assert_eq!(config.mcp.path, "/mcp");
833 assert!(!config.mcp.enabled);
834 }
835
836 #[test]
837 fn test_parse_minimal_config() {
838 let toml = r#"
839 [database]
840 url = "postgres://localhost/myapp"
841 "#;
842
843 let config = ForgeConfig::parse_toml(toml).unwrap();
844 assert_eq!(config.database.url(), "postgres://localhost/myapp");
845 assert_eq!(config.gateway.port, 9081);
846 }
847
848 #[test]
849 fn test_parse_full_config() {
850 let toml = r#"
851 [project]
852 name = "my-app"
853 version = "1.0.0"
854
855 [database]
856 url = "postgres://localhost/myapp"
857 pool_size = 100
858
859 [node]
860 roles = ["gateway", "worker"]
861 worker_capabilities = ["media", "general"]
862
863 [gateway]
864 port = 3000
865 grpc_port = 9001
866 "#;
867
868 let config = ForgeConfig::parse_toml(toml).unwrap();
869 assert_eq!(config.project.name, "my-app");
870 assert_eq!(config.database.pool_size, 100);
871 assert_eq!(config.node.roles.len(), 2);
872 assert_eq!(config.gateway.port, 3000);
873 }
874
875 #[test]
876 fn test_env_var_substitution() {
877 unsafe {
878 std::env::set_var("TEST_DB_URL", "postgres://test:test@localhost/test");
879 }
880
881 let toml = r#"
882 [database]
883 url = "${TEST_DB_URL}"
884 "#;
885
886 let config = ForgeConfig::parse_toml(toml).unwrap();
887 assert_eq!(config.database.url(), "postgres://test:test@localhost/test");
888
889 unsafe {
890 std::env::remove_var("TEST_DB_URL");
891 }
892 }
893
894 #[test]
895 fn test_auth_validation_no_config() {
896 let auth = AuthConfig::default();
897 assert!(auth.validate().is_ok());
898 }
899
900 #[test]
901 fn test_auth_validation_hmac_with_secret() {
902 let auth = AuthConfig {
903 jwt_secret: Some("my-secret".into()),
904 jwt_algorithm: JwtAlgorithm::HS256,
905 ..Default::default()
906 };
907 assert!(auth.validate().is_ok());
908 }
909
910 #[test]
911 fn test_auth_validation_hmac_missing_secret() {
912 let auth = AuthConfig {
913 jwt_issuer: Some("my-issuer".into()),
914 jwt_algorithm: JwtAlgorithm::HS256,
915 ..Default::default()
916 };
917 let result = auth.validate();
918 assert!(result.is_err());
919 let err_msg = result.unwrap_err().to_string();
920 assert!(err_msg.contains("jwt_secret is required"));
921 }
922
923 #[test]
924 fn test_auth_validation_rsa_with_jwks() {
925 let auth = AuthConfig {
926 jwks_url: Some("https://example.com/.well-known/jwks.json".into()),
927 jwt_algorithm: JwtAlgorithm::RS256,
928 ..Default::default()
929 };
930 assert!(auth.validate().is_ok());
931 }
932
933 #[test]
934 fn test_auth_validation_rsa_missing_jwks() {
935 let auth = AuthConfig {
936 jwt_issuer: Some("my-issuer".into()),
937 jwt_algorithm: JwtAlgorithm::RS256,
938 ..Default::default()
939 };
940 let result = auth.validate();
941 assert!(result.is_err());
942 let err_msg = result.unwrap_err().to_string();
943 assert!(err_msg.contains("jwks_url is required"));
944 }
945
946 #[test]
947 fn test_forge_config_validation_fails_on_empty_url() {
948 let toml = r#"
949 [database]
950
951 url = ""
952 "#;
953
954 let result = ForgeConfig::parse_toml(toml);
955 assert!(result.is_err());
956 let err_msg = result.unwrap_err().to_string();
957 assert!(err_msg.contains("database.url is required"));
958 }
959
960 #[test]
961 fn test_forge_config_validation_fails_on_invalid_auth() {
962 let toml = r#"
963 [database]
964
965 url = "postgres://localhost/test"
966
967 [auth]
968 jwt_issuer = "my-issuer"
969 jwt_algorithm = "RS256"
970 "#;
971
972 let result = ForgeConfig::parse_toml(toml);
973 assert!(result.is_err());
974 let err_msg = result.unwrap_err().to_string();
975 assert!(err_msg.contains("jwks_url is required"));
976 }
977
978 #[test]
979 fn test_env_var_default_used_when_unset() {
980 unsafe {
982 std::env::remove_var("TEST_FORGE_OTEL_UNSET");
983 }
984
985 let input = r#"enabled = ${TEST_FORGE_OTEL_UNSET-false}"#;
986 let result = substitute_env_vars(input);
987 assert_eq!(result, "enabled = false");
988 }
989
990 #[test]
991 fn test_env_var_default_overridden_when_set() {
992 unsafe {
993 std::env::set_var("TEST_FORGE_OTEL_SET", "true");
994 }
995
996 let input = r#"enabled = ${TEST_FORGE_OTEL_SET-false}"#;
997 let result = substitute_env_vars(input);
998 assert_eq!(result, "enabled = true");
999
1000 unsafe {
1001 std::env::remove_var("TEST_FORGE_OTEL_SET");
1002 }
1003 }
1004
1005 #[test]
1006 fn test_env_var_colon_dash_default() {
1007 unsafe {
1008 std::env::remove_var("TEST_FORGE_ENDPOINT_UNSET");
1009 }
1010
1011 let input = r#"endpoint = "${TEST_FORGE_ENDPOINT_UNSET:-http://localhost:4318}""#;
1012 let result = substitute_env_vars(input);
1013 assert_eq!(result, r#"endpoint = "http://localhost:4318""#);
1014 }
1015
1016 #[test]
1017 fn test_env_var_no_default_preserves_literal() {
1018 unsafe {
1019 std::env::remove_var("TEST_FORGE_MISSING");
1020 }
1021
1022 let input = r#"url = "${TEST_FORGE_MISSING}""#;
1023 let result = substitute_env_vars(input);
1024 assert_eq!(result, r#"url = "${TEST_FORGE_MISSING}""#);
1025 }
1026
1027 #[test]
1028 fn test_env_var_default_empty_string() {
1029 unsafe {
1030 std::env::remove_var("TEST_FORGE_EMPTY_DEFAULT");
1031 }
1032
1033 let input = r#"val = "${TEST_FORGE_EMPTY_DEFAULT-}""#;
1034 let result = substitute_env_vars(input);
1035 assert_eq!(result, r#"val = """#);
1036 }
1037
1038 #[test]
1039 fn test_observability_config_default_disabled() {
1040 let toml = r#"
1041 [database]
1042 url = "postgres://localhost/test"
1043 "#;
1044
1045 let config = ForgeConfig::parse_toml(toml).unwrap();
1046 assert!(!config.observability.enabled);
1047 assert!(!config.observability.otlp_active());
1048 }
1049
1050 #[test]
1051 fn test_observability_config_with_env_default() {
1052 unsafe {
1054 std::env::remove_var("TEST_OTEL_ENABLED");
1055 }
1056
1057 let toml = r#"
1058 [database]
1059 url = "postgres://localhost/test"
1060
1061 [observability]
1062 enabled = ${TEST_OTEL_ENABLED-false}
1063 "#;
1064
1065 let config = ForgeConfig::parse_toml(toml).unwrap();
1066 assert!(!config.observability.enabled);
1067 }
1068
1069 #[test]
1070 fn test_mcp_config_validation_rejects_invalid_path() {
1071 let toml = r#"
1072 [database]
1073
1074 url = "postgres://localhost/test"
1075
1076 [mcp]
1077 enabled = true
1078 path = "mcp"
1079 "#;
1080
1081 let result = ForgeConfig::parse_toml(toml);
1082 assert!(result.is_err());
1083 let err_msg = result.unwrap_err().to_string();
1084 assert!(err_msg.contains("mcp.path must start with '/'"));
1085 }
1086
1087 #[test]
1088 fn test_access_token_ttl_defaults() {
1089 let auth = AuthConfig::default();
1090 assert_eq!(auth.access_token_ttl_secs(), 3600);
1091 assert_eq!(auth.refresh_token_ttl_days(), 30);
1092 }
1093
1094 #[test]
1095 fn test_access_token_ttl_custom() {
1096 let auth = AuthConfig {
1097 access_token_ttl: Some("15m".into()),
1098 refresh_token_ttl: Some("7d".into()),
1099 ..Default::default()
1100 };
1101 assert_eq!(auth.access_token_ttl_secs(), 900);
1102 assert_eq!(auth.refresh_token_ttl_days(), 7);
1103 }
1104
1105 #[test]
1106 fn test_access_token_ttl_minimum_enforced() {
1107 let auth = AuthConfig {
1108 access_token_ttl: Some("0s".into()),
1109 ..Default::default()
1110 };
1111 assert_eq!(auth.access_token_ttl_secs(), 1);
1113 }
1114
1115 #[test]
1116 fn test_refresh_token_ttl_minimum_enforced() {
1117 let auth = AuthConfig {
1118 refresh_token_ttl: Some("1h".into()),
1119 ..Default::default()
1120 };
1121 assert_eq!(auth.refresh_token_ttl_days(), 1);
1123 }
1124
1125 #[test]
1126 fn test_max_body_size_defaults() {
1127 let gw = GatewayConfig::default();
1128 assert_eq!(gw.max_body_size_bytes().unwrap(), 20 * 1024 * 1024);
1129 }
1130
1131 #[test]
1132 fn test_max_body_size_custom() {
1133 let gw = GatewayConfig {
1134 max_body_size: "100mb".into(),
1135 ..Default::default()
1136 };
1137 assert_eq!(gw.max_body_size_bytes().unwrap(), 100 * 1024 * 1024);
1138 }
1139
1140 #[test]
1141 fn test_max_body_size_invalid_errors() {
1142 let gw = GatewayConfig {
1143 max_body_size: "not-a-size".into(),
1144 ..Default::default()
1145 };
1146 assert!(gw.max_body_size_bytes().is_err());
1147 }
1148
1149 #[test]
1150 fn test_mcp_config_rejects_reserved_paths() {
1151 for reserved in McpConfig::RESERVED_PATHS {
1152 let toml = format!(
1153 r#"
1154 [database]
1155 url = "postgres://localhost/test"
1156
1157 [mcp]
1158 enabled = true
1159 path = "{reserved}"
1160 "#
1161 );
1162
1163 let result = ForgeConfig::parse_toml(&toml);
1164 assert!(result.is_err(), "Expected {reserved} to be rejected");
1165 let err_msg = result.unwrap_err().to_string();
1166 assert!(
1167 err_msg.contains("conflicts with a reserved gateway route"),
1168 "Wrong error for {reserved}: {err_msg}"
1169 );
1170 }
1171 }
1172}