Skip to main content

forge_core/config/
mod.rs

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/// Root configuration for FORGE.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ForgeConfig {
17    /// Project metadata.
18    #[serde(default)]
19    pub project: ProjectConfig,
20
21    /// Database configuration.
22    pub database: DatabaseConfig,
23
24    /// Node configuration.
25    #[serde(default)]
26    pub node: NodeConfig,
27
28    /// Gateway configuration.
29    #[serde(default)]
30    pub gateway: GatewayConfig,
31
32    /// Function execution configuration.
33    #[serde(default)]
34    pub function: FunctionConfig,
35
36    /// Worker configuration.
37    #[serde(default)]
38    pub worker: WorkerConfig,
39
40    /// Cluster configuration.
41    #[serde(default)]
42    pub cluster: ClusterConfig,
43
44    /// Security configuration.
45    #[serde(default)]
46    pub security: SecurityConfig,
47
48    /// Authentication configuration.
49    #[serde(default)]
50    pub auth: AuthConfig,
51
52    /// Observability configuration.
53    #[serde(default)]
54    pub observability: ObservabilityConfig,
55
56    /// MCP server configuration.
57    #[serde(default)]
58    pub mcp: McpConfig,
59
60    /// Signals configuration for product analytics and diagnostics.
61    #[serde(default)]
62    pub signals: SignalsConfig,
63}
64
65impl ForgeConfig {
66    /// Load configuration from a TOML file.
67    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    /// Parse configuration from a TOML string.
75    pub fn parse_toml(content: &str) -> Result<Self> {
76        // Substitute environment variables
77        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    /// Validate the configuration for invalid combinations.
87    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        // Cross-field: OAuth requires jwt_secret for signing tokens
94        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    /// Load configuration with defaults.
112    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/// Project metadata.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct ProjectConfig {
133    /// Project name.
134    #[serde(default = "default_project_name")]
135    pub name: String,
136
137    /// Project version.
138    #[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/// Node role configuration.
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct NodeConfig {
162    /// Roles this node should assume.
163    #[serde(default = "default_roles")]
164    pub roles: Vec<NodeRole>,
165
166    /// Worker capabilities for job routing.
167    #[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/// Available node roles.
194#[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/// Gateway configuration.
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct GatewayConfig {
206    /// HTTP port.
207    #[serde(default = "default_http_port")]
208    pub port: u16,
209
210    /// gRPC port for inter-node communication (reserved for future use).
211    ///
212    /// This port is registered in the cluster node info but a gRPC listener
213    /// is not yet started. It will be used for efficient binary inter-node
214    /// RPC in a future release.
215    #[serde(default = "default_grpc_port")]
216    pub grpc_port: u16,
217
218    /// Maximum concurrent connections.
219    #[serde(default = "default_max_connections")]
220    pub max_connections: usize,
221
222    /// Maximum active SSE sessions.
223    #[serde(default = "default_sse_max_sessions")]
224    pub sse_max_sessions: usize,
225
226    /// Request timeout in seconds.
227    #[serde(default = "default_request_timeout")]
228    pub request_timeout_secs: u64,
229
230    /// Enable CORS handling.
231    #[serde(default = "default_cors_enabled")]
232    pub cors_enabled: bool,
233
234    /// Allowed CORS origins.
235    #[serde(default = "default_cors_origins")]
236    pub cors_origins: Vec<String>,
237
238    /// Routes excluded from request logs, metrics, and traces.
239    /// Defaults to `["/_api/health", "/_api/ready"]`. Set to `[]` to monitor everything.
240    #[serde(default = "default_quiet_routes")]
241    pub quiet_routes: Vec<String>,
242
243    /// Maximum request body size (e.g. "100mb", "1gb"). Defaults to "20mb".
244    #[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    /// Parse `max_body_size` into bytes.
266    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/// Function execution configuration.
320#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct FunctionConfig {
322    /// Maximum concurrent function executions.
323    #[serde(default = "default_max_concurrent")]
324    pub max_concurrent: usize,
325
326    /// Function timeout in seconds.
327    #[serde(default = "default_function_timeout")]
328    pub timeout_secs: u64,
329
330    /// Advisory memory limit per function execution (in bytes).
331    ///
332    /// This value is exposed as configuration metadata for orchestrators
333    /// (e.g., Kubernetes resource requests) and observability dashboards.
334    /// It is not enforced at the process level since Rust does not provide
335    /// per-function memory sandboxing. Use container-level limits for hard
336    /// enforcement.
337    #[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 // 512 MiB
361}
362
363/// Worker configuration.
364#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct WorkerConfig {
366    /// Maximum concurrent jobs.
367    #[serde(default = "default_max_concurrent_jobs")]
368    pub max_concurrent_jobs: usize,
369
370    /// Job timeout in seconds.
371    #[serde(default = "default_job_timeout")]
372    pub job_timeout_secs: u64,
373
374    /// Poll interval in milliseconds.
375    #[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 // 1 hour
395}
396
397fn default_poll_interval() -> u64 {
398    100
399}
400
401/// Security configuration.
402#[derive(Debug, Clone, Serialize, Deserialize, Default)]
403pub struct SecurityConfig {
404    /// Secret key for signing.
405    pub secret_key: Option<String>,
406}
407
408/// JWT signing algorithm.
409#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
410#[serde(rename_all = "UPPERCASE")]
411pub enum JwtAlgorithm {
412    /// HMAC using SHA-256 (symmetric, requires jwt_secret).
413    #[default]
414    HS256,
415    /// HMAC using SHA-384 (symmetric, requires jwt_secret).
416    HS384,
417    /// HMAC using SHA-512 (symmetric, requires jwt_secret).
418    HS512,
419    /// RSA using SHA-256 (asymmetric, requires jwks_url).
420    RS256,
421    /// RSA using SHA-384 (asymmetric, requires jwks_url).
422    RS384,
423    /// RSA using SHA-512 (asymmetric, requires jwks_url).
424    RS512,
425}
426
427/// Authentication configuration.
428#[derive(Debug, Clone, Serialize, Deserialize)]
429pub struct AuthConfig {
430    /// JWT secret for HMAC algorithms (HS256, HS384, HS512).
431    /// Required when using HMAC algorithms.
432    pub jwt_secret: Option<String>,
433
434    /// JWT signing algorithm.
435    /// HMAC algorithms (HS256, HS384, HS512) require jwt_secret.
436    /// RSA algorithms (RS256, RS384, RS512) require jwks_url.
437    #[serde(default)]
438    pub jwt_algorithm: JwtAlgorithm,
439
440    /// Expected token issuer (iss claim).
441    /// If set, tokens with a different issuer are rejected.
442    pub jwt_issuer: Option<String>,
443
444    /// Expected audience (aud claim).
445    /// If set, tokens with a different audience are rejected.
446    pub jwt_audience: Option<String>,
447
448    /// Access token lifetime (e.g., "15m", "1h").
449    /// Used by `ctx.issue_token_pair()`. Defaults to "1h".
450    pub access_token_ttl: Option<String>,
451
452    /// Refresh token lifetime (e.g., "7d", "30d").
453    /// Used by `ctx.issue_token_pair()`. Defaults to "30d".
454    pub refresh_token_ttl: Option<String>,
455
456    /// JWKS URL for RSA algorithms (RS256, RS384, RS512).
457    /// Keys are fetched and cached automatically.
458    pub jwks_url: Option<String>,
459
460    /// JWKS cache TTL in seconds.
461    #[serde(default = "default_jwks_cache_ttl")]
462    pub jwks_cache_ttl_secs: u64,
463
464    /// Session TTL in seconds (for WebSocket sessions).
465    #[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    /// Resolved access token TTL in seconds.
487    /// Parses `access_token_ttl`, default 3600s (1h).
488    /// Minimum 1 second to prevent zero-lifetime tokens.
489    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    /// Resolved refresh token TTL in days.
498    /// Parses `refresh_token_ttl`, default 30 days.
499    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    /// Check if auth is configured (any credential or claim validation is set).
509    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    /// Validate that the configuration is complete for the chosen algorithm.
517    /// Skips validation if no auth settings are configured (auth disabled).
518    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    /// Check if this config uses HMAC (symmetric) algorithms.
549    pub fn is_hmac(&self) -> bool {
550        matches!(
551            self.jwt_algorithm,
552            JwtAlgorithm::HS256 | JwtAlgorithm::HS384 | JwtAlgorithm::HS512
553        )
554    }
555
556    /// Check if this config uses RSA (asymmetric) algorithms.
557    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 // 1 hour
567}
568
569fn default_session_ttl() -> u64 {
570    7 * 24 * 60 * 60 // 7 days
571}
572
573/// Observability configuration for OTLP telemetry.
574#[derive(Debug, Clone, Serialize, Deserialize)]
575pub struct ObservabilityConfig {
576    /// Enable observability (traces, metrics, logs).
577    #[serde(default)]
578    pub enabled: bool,
579
580    /// OTLP endpoint for telemetry export.
581    #[serde(default = "default_otlp_endpoint")]
582    pub otlp_endpoint: String,
583
584    /// Service name for telemetry identification.
585    pub service_name: Option<String>,
586
587    /// Enable distributed tracing.
588    #[serde(default = "default_true")]
589    pub enable_traces: bool,
590
591    /// Enable metrics collection.
592    #[serde(default = "default_true")]
593    pub enable_metrics: bool,
594
595    /// Enable log export via OTLP.
596    #[serde(default = "default_true")]
597    pub enable_logs: bool,
598
599    /// Trace sampling ratio (0.0 to 1.0).
600    #[serde(default = "default_sampling_ratio")]
601    pub sampling_ratio: f64,
602
603    /// Log level for the tracing subscriber (e.g., "debug", "info", "warn").
604    #[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    /// Apply FORGE_OTEL_* environment variable overrides.
629    ///
630    /// Supported variables:
631    /// - `FORGE_OTEL_TRACES` - "true"/"false" to enable/disable traces
632    /// - `FORGE_OTEL_METRICS` - "true"/"false" to enable/disable metrics
633    /// - `FORGE_OTEL_LOGS` - "true"/"false" to enable/disable logs
634    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/// MCP server configuration.
664#[derive(Debug, Clone, Serialize, Deserialize)]
665pub struct McpConfig {
666    /// Enable MCP endpoint exposure.
667    #[serde(default)]
668    pub enabled: bool,
669
670    /// Enable OAuth 2.1 Authorization Code + PKCE for MCP clients.
671    /// When true, Forge acts as an OAuth 2.1 Authorization Server so MCP
672    /// clients like Claude Code can auto-authenticate via browser login.
673    /// Requires `auth.jwt_secret` to be set.
674    #[serde(default)]
675    pub oauth: bool,
676
677    /// MCP endpoint path under the gateway API namespace.
678    #[serde(default = "default_mcp_path")]
679    pub path: String,
680
681    /// Session TTL in seconds.
682    #[serde(default = "default_mcp_session_ttl_secs")]
683    pub session_ttl_secs: u64,
684
685    /// Allowed origins for Origin header validation.
686    #[serde(default)]
687    pub allowed_origins: Vec<String>,
688
689    /// Enforce MCP-Protocol-Version header on post-initialize requests.
690    #[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    /// Paths reserved by the gateway that MCP must not collide with.
709    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/// Substitute environment variables in the format `${VAR_NAME}`.
756///
757/// Supports default values with `${VAR-default}` or `${VAR:-default}`.
758/// When the env var is unset, the default is used. Without a default,
759/// the literal `${VAR}` is preserved (so TOML parsing can still fail
760/// loudly if a required variable is missing).
761#[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            // Split on first `-` or `:-` for default value support
777            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
798/// Parse `VAR-default` or `VAR:-default` into (name, optional default).
799/// Both forms behave identically (fallback when unset). `:-` is checked
800/// first so its `-` doesn't get matched by the plain `-` branch.
801fn 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        // Ensure the var is definitely not set
981        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        // Simulates what the template produces when no env vars are set
1053        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        // Should floor at 1, not 0
1112        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        // 1 hour < 1 day, so should floor at 1 day
1122        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}