Skip to main content

forge_config/
lib.rs

1#![warn(missing_docs)]
2
3//! # forge-config
4//!
5//! Configuration loading for the Forgemax Code Mode MCP Gateway.
6//!
7//! Supports TOML configuration files with environment variable expansion.
8//!
9//! ## Example
10//!
11//! ```toml
12//! [servers.narsil]
13//! command = "narsil-mcp"
14//! args = ["--repos", "."]
15//! transport = "stdio"
16//!
17//! [servers.github]
18//! url = "https://mcp.github.com/mcp"
19//! transport = "sse"
20//! headers = { Authorization = "Bearer ${GITHUB_TOKEN}" }
21//!
22//! [sandbox]
23//! timeout_secs = 5
24//! max_heap_mb = 64
25//! max_concurrent = 8
26//! max_tool_calls = 50
27//! ```
28
29#[cfg(feature = "config-watch")]
30pub mod watcher;
31
32use std::collections::HashMap;
33use std::path::Path;
34
35use serde::Deserialize;
36use thiserror::Error;
37
38/// Errors from config parsing.
39#[derive(Debug, Error)]
40#[non_exhaustive]
41pub enum ConfigError {
42    /// Failed to read config file.
43    #[error("failed to read config file: {0}")]
44    Io(#[from] std::io::Error),
45
46    /// Failed to parse TOML.
47    #[error("failed to parse config: {0}")]
48    Parse(#[from] toml::de::Error),
49
50    /// Invalid configuration value.
51    #[error("invalid config: {0}")]
52    Invalid(String),
53}
54
55/// Top-level Forge configuration.
56#[derive(Debug, Clone, Deserialize)]
57pub struct ForgeConfig {
58    /// Downstream MCP server configurations, keyed by server name.
59    #[serde(default)]
60    pub servers: HashMap<String, ServerConfig>,
61
62    /// Sandbox execution settings.
63    #[serde(default)]
64    pub sandbox: SandboxOverrides,
65
66    /// Server group definitions for cross-server data flow policies.
67    #[serde(default)]
68    pub groups: HashMap<String, GroupConfig>,
69
70    /// Manifest refresh behavior.
71    #[serde(default)]
72    pub manifest: ManifestConfig,
73}
74
75/// Configuration for manifest refresh behavior.
76#[derive(Debug, Clone, Default, Deserialize)]
77pub struct ManifestConfig {
78    /// How often to re-discover tools from downstream servers (seconds).
79    /// 0 or absent = disabled (manifest is static after startup).
80    #[serde(default)]
81    pub refresh_interval_secs: Option<u64>,
82}
83
84/// Configuration for a server group.
85#[derive(Debug, Clone, Deserialize)]
86pub struct GroupConfig {
87    /// Server names belonging to this group.
88    pub servers: Vec<String>,
89
90    /// Isolation mode: "strict" (no cross-group data flow) or "open" (unrestricted).
91    #[serde(default = "default_isolation")]
92    pub isolation: String,
93}
94
95fn default_isolation() -> String {
96    "open".to_string()
97}
98
99/// Configuration for a single downstream MCP server.
100#[derive(Debug, Clone, Deserialize)]
101pub struct ServerConfig {
102    /// Transport type: "stdio" or "sse".
103    pub transport: String,
104
105    /// Command to execute (stdio transport).
106    #[serde(default)]
107    pub command: Option<String>,
108
109    /// Command arguments (stdio transport).
110    #[serde(default)]
111    pub args: Vec<String>,
112
113    /// Server URL (sse transport).
114    #[serde(default)]
115    pub url: Option<String>,
116
117    /// HTTP headers (sse transport).
118    #[serde(default)]
119    pub headers: HashMap<String, String>,
120
121    /// Server description (optional, for manifest).
122    #[serde(default)]
123    pub description: Option<String>,
124
125    /// Per-server timeout in seconds for individual tool calls.
126    #[serde(default)]
127    pub timeout_secs: Option<u64>,
128
129    /// Enable circuit breaker for this server.
130    #[serde(default)]
131    pub circuit_breaker: Option<bool>,
132
133    /// Number of consecutive failures before opening the circuit (default: 3).
134    #[serde(default)]
135    pub failure_threshold: Option<u32>,
136
137    /// Seconds to wait before probing a tripped circuit (default: 30).
138    #[serde(default)]
139    pub recovery_timeout_secs: Option<u64>,
140
141    /// Enable automatic reconnection on transport death (default: true for stdio).
142    #[serde(default)]
143    pub reconnect: Option<bool>,
144
145    /// Maximum reconnect backoff in seconds (default: 30).
146    #[serde(default)]
147    pub max_reconnect_backoff_secs: Option<u64>,
148}
149
150/// Sandbox configuration overrides.
151#[derive(Debug, Clone, Default, Deserialize)]
152pub struct SandboxOverrides {
153    /// Execution timeout in seconds.
154    #[serde(default)]
155    pub timeout_secs: Option<u64>,
156
157    /// Maximum V8 heap size in megabytes.
158    #[serde(default)]
159    pub max_heap_mb: Option<usize>,
160
161    /// Maximum concurrent sandbox executions.
162    #[serde(default)]
163    pub max_concurrent: Option<usize>,
164
165    /// Maximum tool calls per execution.
166    #[serde(default)]
167    pub max_tool_calls: Option<usize>,
168
169    /// Execution mode: "in_process" (default) or "child_process".
170    #[serde(default)]
171    pub execution_mode: Option<String>,
172
173    /// Maximum IPC message size in megabytes (default: 8 MB).
174    #[serde(default)]
175    pub max_ipc_message_size_mb: Option<usize>,
176
177    /// Maximum resource content size in megabytes (default: 64 MB).
178    #[serde(default)]
179    pub max_resource_size_mb: Option<usize>,
180
181    /// Maximum concurrent calls in forge.parallel() (default: 8).
182    #[serde(default)]
183    pub max_parallel: Option<usize>,
184
185    /// Maximum number of servers to connect to concurrently at startup.
186    /// Defaults to 8. Set to 1 for sequential startup.
187    #[serde(default)]
188    pub startup_concurrency: Option<usize>,
189
190    /// Stash configuration overrides.
191    #[serde(default)]
192    pub stash: Option<StashOverrides>,
193
194    /// Worker pool configuration overrides.
195    #[serde(default)]
196    pub pool: Option<PoolOverrides>,
197}
198
199/// Configuration overrides for the worker pool.
200///
201/// When enabled, warm worker processes are reused across executions
202/// instead of spawning a new process each time (~5-10ms vs ~50ms).
203#[derive(Debug, Clone, Default, Deserialize)]
204pub struct PoolOverrides {
205    /// Enable the worker pool (default: false).
206    #[serde(default)]
207    pub enabled: Option<bool>,
208
209    /// Minimum number of warm workers to keep ready (default: 2).
210    #[serde(default)]
211    pub min_workers: Option<usize>,
212
213    /// Maximum number of workers in the pool (default: 8).
214    #[serde(default)]
215    pub max_workers: Option<usize>,
216
217    /// Kill idle workers after this many seconds (default: 60).
218    #[serde(default)]
219    pub max_idle_secs: Option<u64>,
220
221    /// Recycle a worker after this many executions (default: 50).
222    #[serde(default)]
223    pub max_uses: Option<u32>,
224}
225
226/// Configuration overrides for the ephemeral stash.
227#[derive(Debug, Clone, Default, Deserialize)]
228pub struct StashOverrides {
229    /// Maximum number of stash entries per session.
230    #[serde(default)]
231    pub max_keys: Option<usize>,
232
233    /// Maximum size of a single stash value in megabytes.
234    #[serde(default)]
235    pub max_value_size_mb: Option<usize>,
236
237    /// Maximum total stash size in megabytes.
238    #[serde(default)]
239    pub max_total_size_mb: Option<usize>,
240
241    /// Default TTL for stash entries in seconds.
242    #[serde(default)]
243    pub default_ttl_secs: Option<u64>,
244
245    /// Maximum TTL for stash entries in seconds.
246    #[serde(default)]
247    pub max_ttl_secs: Option<u64>,
248
249    /// Maximum stash operations per execution (None = unlimited).
250    #[serde(default)]
251    pub max_calls: Option<usize>,
252}
253
254impl ForgeConfig {
255    /// Parse a config from a TOML string.
256    pub fn from_toml(toml_str: &str) -> Result<Self, ConfigError> {
257        let config: ForgeConfig = toml::from_str(toml_str)?;
258        config.validate()?;
259        Ok(config)
260    }
261
262    /// Load config from a file path.
263    pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
264        let content = std::fs::read_to_string(path)?;
265        Self::from_toml(&content)
266    }
267
268    /// Parse a config from a TOML string, expanding `${ENV_VAR}` references.
269    pub fn from_toml_with_env(toml_str: &str) -> Result<Self, ConfigError> {
270        let expanded = expand_env_vars(toml_str);
271        Self::from_toml(&expanded)
272    }
273
274    /// Load config from a file path, expanding environment variables.
275    pub fn from_file_with_env(path: &Path) -> Result<Self, ConfigError> {
276        let content = std::fs::read_to_string(path)?;
277        Self::from_toml_with_env(&content)
278    }
279
280    fn validate(&self) -> Result<(), ConfigError> {
281        for (name, server) in &self.servers {
282            match server.transport.as_str() {
283                "stdio" => {
284                    if server.command.is_none() {
285                        return Err(ConfigError::Invalid(format!(
286                            "server '{}': stdio transport requires 'command'",
287                            name
288                        )));
289                    }
290                }
291                "sse" => {
292                    if server.url.is_none() {
293                        return Err(ConfigError::Invalid(format!(
294                            "server '{}': sse transport requires 'url'",
295                            name
296                        )));
297                    }
298                }
299                other => {
300                    return Err(ConfigError::Invalid(format!(
301                        "server '{}': unsupported transport '{}', supported: stdio, sse",
302                        name, other
303                    )));
304                }
305            }
306        }
307
308        // Validate groups
309        let mut seen_servers: HashMap<&str, &str> = HashMap::new();
310        for (group_name, group_config) in &self.groups {
311            // Validate isolation mode
312            match group_config.isolation.as_str() {
313                "strict" | "open" => {}
314                other => {
315                    return Err(ConfigError::Invalid(format!(
316                        "group '{}': unsupported isolation '{}', supported: strict, open",
317                        group_name, other
318                    )));
319                }
320            }
321
322            for server_ref in &group_config.servers {
323                // Check server exists
324                if !self.servers.contains_key(server_ref) {
325                    return Err(ConfigError::Invalid(format!(
326                        "group '{}': references unknown server '{}'",
327                        group_name, server_ref
328                    )));
329                }
330                // Check no server in multiple groups
331                if let Some(existing_group) = seen_servers.get(server_ref.as_str()) {
332                    return Err(ConfigError::Invalid(format!(
333                        "server '{}' is in multiple groups: '{}' and '{}'",
334                        server_ref, existing_group, group_name
335                    )));
336                }
337                seen_servers.insert(server_ref, group_name);
338            }
339        }
340
341        // Validate sandbox v0.2 fields
342        self.validate_sandbox_v2()?;
343
344        Ok(())
345    }
346
347    fn validate_sandbox_v2(&self) -> Result<(), ConfigError> {
348        // CV-01: max_resource_size_mb must be > 0 and <= 512
349        if let Some(size) = self.sandbox.max_resource_size_mb {
350            if size == 0 || size > 512 {
351                return Err(ConfigError::Invalid(
352                    "sandbox.max_resource_size_mb must be > 0 and <= 512".into(),
353                ));
354            }
355        }
356
357        // CV-02: max_parallel must be >= 1 and <= max_concurrent (or default 8)
358        if let Some(parallel) = self.sandbox.max_parallel {
359            let max_concurrent = self.sandbox.max_concurrent.unwrap_or(8);
360            if parallel < 1 || parallel > max_concurrent {
361                return Err(ConfigError::Invalid(format!(
362                    "sandbox.max_parallel must be >= 1 and <= max_concurrent ({})",
363                    max_concurrent
364                )));
365            }
366        }
367
368        if let Some(ref stash) = self.sandbox.stash {
369            // CV-03: stash.max_value_size_mb must be > 0 and <= 256
370            if let Some(size) = stash.max_value_size_mb {
371                if size == 0 || size > 256 {
372                    return Err(ConfigError::Invalid(
373                        "sandbox.stash.max_value_size_mb must be > 0 and <= 256".into(),
374                    ));
375                }
376            }
377
378            // CV-04: stash.max_total_size_mb must be >= stash.max_value_size_mb
379            if let (Some(total), Some(value)) = (stash.max_total_size_mb, stash.max_value_size_mb) {
380                if total < value {
381                    return Err(ConfigError::Invalid(
382                        "sandbox.stash.max_total_size_mb must be >= sandbox.stash.max_value_size_mb"
383                            .into(),
384                    ));
385                }
386            }
387
388            // CV-05: stash.default_ttl_secs must be > 0 and <= stash.max_ttl_secs
389            if let Some(default_ttl) = stash.default_ttl_secs {
390                if default_ttl == 0 {
391                    return Err(ConfigError::Invalid(
392                        "sandbox.stash.default_ttl_secs must be > 0".into(),
393                    ));
394                }
395                let max_ttl = stash.max_ttl_secs.unwrap_or(86400);
396                if default_ttl > max_ttl {
397                    return Err(ConfigError::Invalid(format!(
398                        "sandbox.stash.default_ttl_secs ({}) must be <= max_ttl_secs ({})",
399                        default_ttl, max_ttl
400                    )));
401                }
402            }
403
404            // CV-06: stash.max_ttl_secs must be > 0 and <= 604800 (7 days)
405            if let Some(max_ttl) = stash.max_ttl_secs {
406                if max_ttl == 0 || max_ttl > 604800 {
407                    return Err(ConfigError::Invalid(
408                        "sandbox.stash.max_ttl_secs must be > 0 and <= 604800 (7 days)".into(),
409                    ));
410                }
411            }
412        }
413
414        // CV-07: max_resource_size_mb + 1 must fit within IPC message size
415        // In child_process mode, resource content flows over IPC
416        if let Some(resource_mb) = self.sandbox.max_resource_size_mb {
417            let ipc_limit_mb = self.sandbox.max_ipc_message_size_mb.unwrap_or(8); // default 8 MB
418            if resource_mb + 1 > ipc_limit_mb {
419                return Err(ConfigError::Invalid(format!(
420                    "sandbox.max_resource_size_mb ({}) + 1 MB overhead exceeds IPC message limit ({} MB)",
421                    resource_mb, ipc_limit_mb
422                )));
423            }
424        }
425
426        // CV-12: startup_concurrency must be >= 1 and <= 64
427        if let Some(sc) = self.sandbox.startup_concurrency {
428            if sc == 0 || sc > 64 {
429                return Err(ConfigError::Invalid(
430                    "sandbox.startup_concurrency must be >= 1 and <= 64".into(),
431                ));
432            }
433        }
434
435        // Validate pool config
436        if let Some(ref pool) = self.sandbox.pool {
437            self.validate_pool(pool)?;
438        }
439
440        Ok(())
441    }
442
443    fn validate_pool(&self, pool: &PoolOverrides) -> Result<(), ConfigError> {
444        let max_concurrent = self.sandbox.max_concurrent.unwrap_or(8);
445
446        // CV-08: max_workers must be >= 1 and <= max_concurrent
447        if let Some(max) = pool.max_workers {
448            if max == 0 || max > max_concurrent {
449                return Err(ConfigError::Invalid(format!(
450                    "sandbox.pool.max_workers must be >= 1 and <= max_concurrent ({})",
451                    max_concurrent
452                )));
453            }
454        }
455
456        let max_workers = pool.max_workers.unwrap_or(8);
457
458        // CV-09: min_workers must be >= 0 and <= max_workers
459        if let Some(min) = pool.min_workers {
460            if min > max_workers {
461                return Err(ConfigError::Invalid(format!(
462                    "sandbox.pool.min_workers ({}) must be <= max_workers ({})",
463                    min, max_workers
464                )));
465            }
466        }
467
468        // CV-10: max_uses must be > 0
469        if let Some(uses) = pool.max_uses {
470            if uses == 0 {
471                return Err(ConfigError::Invalid(
472                    "sandbox.pool.max_uses must be > 0".into(),
473                ));
474            }
475        }
476
477        // CV-11: max_idle_secs must be >= 5 and <= 3600
478        if let Some(idle) = pool.max_idle_secs {
479            if !(5..=3600).contains(&idle) {
480                return Err(ConfigError::Invalid(
481                    "sandbox.pool.max_idle_secs must be >= 5 and <= 3600".into(),
482                ));
483            }
484        }
485
486        Ok(())
487    }
488}
489
490/// Default startup concurrency for connecting to downstream servers.
491pub fn default_startup_concurrency() -> usize {
492    8
493}
494
495/// Expand `${ENV_VAR}` patterns in a string using environment variables.
496fn expand_env_vars(input: &str) -> String {
497    let mut result = String::with_capacity(input.len());
498    let mut chars = input.chars().peekable();
499
500    while let Some(ch) = chars.next() {
501        if ch == '$' && chars.peek() == Some(&'{') {
502            chars.next(); // consume '{'
503            let mut var_name = String::new();
504            for c in chars.by_ref() {
505                if c == '}' {
506                    break;
507                }
508                var_name.push(c);
509            }
510            match std::env::var(&var_name) {
511                Ok(value) => result.push_str(&value),
512                Err(_) => {
513                    // Leave the placeholder if env var not found
514                    result.push_str(&format!("${{{}}}", var_name));
515                }
516            }
517        } else {
518            result.push(ch);
519        }
520    }
521
522    result
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    #[test]
530    fn config_parses_minimal_toml() {
531        let toml = r#"
532            [servers.narsil]
533            command = "narsil-mcp"
534            transport = "stdio"
535        "#;
536
537        let config = ForgeConfig::from_toml(toml).unwrap();
538        assert_eq!(config.servers.len(), 1);
539        let narsil = &config.servers["narsil"];
540        assert_eq!(narsil.transport, "stdio");
541        assert_eq!(narsil.command.as_deref(), Some("narsil-mcp"));
542    }
543
544    #[test]
545    fn config_parses_sse_server() {
546        let toml = r#"
547            [servers.github]
548            url = "https://mcp.github.com/sse"
549            transport = "sse"
550        "#;
551
552        let config = ForgeConfig::from_toml(toml).unwrap();
553        let github = &config.servers["github"];
554        assert_eq!(github.transport, "sse");
555        assert_eq!(github.url.as_deref(), Some("https://mcp.github.com/sse"));
556    }
557
558    #[test]
559    fn config_parses_sandbox_overrides() {
560        let toml = r#"
561            [sandbox]
562            timeout_secs = 10
563            max_heap_mb = 128
564            max_concurrent = 4
565            max_tool_calls = 100
566        "#;
567
568        let config = ForgeConfig::from_toml(toml).unwrap();
569        assert_eq!(config.sandbox.timeout_secs, Some(10));
570        assert_eq!(config.sandbox.max_heap_mb, Some(128));
571        assert_eq!(config.sandbox.max_concurrent, Some(4));
572        assert_eq!(config.sandbox.max_tool_calls, Some(100));
573    }
574
575    #[test]
576    fn config_expands_environment_variables() {
577        temp_env::with_var("FORGE_TEST_TOKEN", Some("secret123"), || {
578            let toml = r#"
579                [servers.github]
580                url = "https://mcp.github.com/sse"
581                transport = "sse"
582                headers = { Authorization = "Bearer ${FORGE_TEST_TOKEN}" }
583            "#;
584
585            let config = ForgeConfig::from_toml_with_env(toml).unwrap();
586            let github = &config.servers["github"];
587            assert_eq!(
588                github.headers.get("Authorization").unwrap(),
589                "Bearer secret123"
590            );
591        });
592    }
593
594    #[test]
595    fn config_rejects_invalid_transport() {
596        let toml = r#"
597            [servers.test]
598            command = "test"
599            transport = "grpc"
600        "#;
601
602        let err = ForgeConfig::from_toml(toml).unwrap_err();
603        let msg = err.to_string();
604        assert!(
605            msg.contains("grpc"),
606            "error should mention the transport: {msg}"
607        );
608        assert!(
609            msg.contains("stdio"),
610            "error should mention supported transports: {msg}"
611        );
612    }
613
614    #[test]
615    fn config_rejects_stdio_without_command() {
616        let toml = r#"
617            [servers.test]
618            transport = "stdio"
619        "#;
620
621        let err = ForgeConfig::from_toml(toml).unwrap_err();
622        assert!(err.to_string().contains("command"));
623    }
624
625    #[test]
626    fn config_rejects_sse_without_url() {
627        let toml = r#"
628            [servers.test]
629            transport = "sse"
630        "#;
631
632        let err = ForgeConfig::from_toml(toml).unwrap_err();
633        assert!(err.to_string().contains("url"));
634    }
635
636    #[test]
637    fn config_loads_from_file() {
638        let dir = std::env::temp_dir().join("forge-config-test");
639        std::fs::create_dir_all(&dir).unwrap();
640        let path = dir.join("forge.toml");
641        std::fs::write(
642            &path,
643            r#"
644            [servers.test]
645            command = "test-server"
646            transport = "stdio"
647        "#,
648        )
649        .unwrap();
650
651        let config = ForgeConfig::from_file(&path).unwrap();
652        assert_eq!(config.servers.len(), 1);
653        assert_eq!(
654            config.servers["test"].command.as_deref(),
655            Some("test-server")
656        );
657
658        std::fs::remove_dir_all(&dir).ok();
659    }
660
661    #[test]
662    fn config_uses_defaults_when_absent() {
663        let toml = r#"
664            [servers.test]
665            command = "test"
666            transport = "stdio"
667        "#;
668
669        let config = ForgeConfig::from_toml(toml).unwrap();
670        assert!(config.sandbox.timeout_secs.is_none());
671        assert!(config.sandbox.max_heap_mb.is_none());
672        assert!(config.sandbox.max_concurrent.is_none());
673        assert!(config.sandbox.max_tool_calls.is_none());
674    }
675
676    #[test]
677    fn config_parses_full_example() {
678        let toml = r#"
679            [servers.narsil]
680            command = "narsil-mcp"
681            args = ["--repos", ".", "--streaming"]
682            transport = "stdio"
683            description = "Code intelligence"
684
685            [servers.github]
686            url = "https://mcp.github.com/sse"
687            transport = "sse"
688            headers = { Authorization = "Bearer token123" }
689
690            [sandbox]
691            timeout_secs = 5
692            max_heap_mb = 64
693            max_concurrent = 8
694            max_tool_calls = 50
695        "#;
696
697        let config = ForgeConfig::from_toml(toml).unwrap();
698        assert_eq!(config.servers.len(), 2);
699
700        let narsil = &config.servers["narsil"];
701        assert_eq!(narsil.command.as_deref(), Some("narsil-mcp"));
702        assert_eq!(narsil.args, vec!["--repos", ".", "--streaming"]);
703        assert_eq!(narsil.description.as_deref(), Some("Code intelligence"));
704
705        let github = &config.servers["github"];
706        assert_eq!(github.url.as_deref(), Some("https://mcp.github.com/sse"));
707        assert_eq!(
708            github.headers.get("Authorization").unwrap(),
709            "Bearer token123"
710        );
711
712        assert_eq!(config.sandbox.timeout_secs, Some(5));
713    }
714
715    #[test]
716    fn config_empty_servers_is_valid() {
717        let toml = "";
718        let config = ForgeConfig::from_toml(toml).unwrap();
719        assert!(config.servers.is_empty());
720    }
721
722    #[test]
723    fn env_var_expansion_preserves_unresolved() {
724        let result = expand_env_vars("prefix ${DEFINITELY_NOT_SET_12345} suffix");
725        assert_eq!(result, "prefix ${DEFINITELY_NOT_SET_12345} suffix");
726    }
727
728    #[test]
729    fn env_var_expansion_handles_no_vars() {
730        let result = expand_env_vars("no variables here");
731        assert_eq!(result, "no variables here");
732    }
733
734    #[test]
735    fn config_parses_execution_mode_child_process() {
736        let toml = r#"
737            [sandbox]
738            execution_mode = "child_process"
739        "#;
740
741        let config = ForgeConfig::from_toml(toml).unwrap();
742        assert_eq!(
743            config.sandbox.execution_mode.as_deref(),
744            Some("child_process")
745        );
746    }
747
748    #[test]
749    fn config_parses_groups() {
750        let toml = r#"
751            [servers.vault]
752            command = "vault-mcp"
753            transport = "stdio"
754
755            [servers.slack]
756            command = "slack-mcp"
757            transport = "stdio"
758
759            [groups.internal]
760            servers = ["vault"]
761            isolation = "strict"
762
763            [groups.external]
764            servers = ["slack"]
765            isolation = "open"
766        "#;
767
768        let config = ForgeConfig::from_toml(toml).unwrap();
769        assert_eq!(config.groups.len(), 2);
770        assert_eq!(config.groups["internal"].isolation, "strict");
771        assert_eq!(config.groups["external"].servers, vec!["slack"]);
772    }
773
774    #[test]
775    fn config_groups_default_to_empty() {
776        let toml = r#"
777            [servers.test]
778            command = "test"
779            transport = "stdio"
780        "#;
781        let config = ForgeConfig::from_toml(toml).unwrap();
782        assert!(config.groups.is_empty());
783    }
784
785    #[test]
786    fn config_rejects_group_with_unknown_server() {
787        let toml = r#"
788            [servers.real]
789            command = "real"
790            transport = "stdio"
791
792            [groups.bad]
793            servers = ["nonexistent"]
794        "#;
795        let err = ForgeConfig::from_toml(toml).unwrap_err();
796        let msg = err.to_string();
797        assert!(msg.contains("nonexistent"), "should mention server: {msg}");
798        assert!(msg.contains("unknown"), "should say unknown: {msg}");
799    }
800
801    #[test]
802    fn config_rejects_server_in_multiple_groups() {
803        let toml = r#"
804            [servers.shared]
805            command = "shared"
806            transport = "stdio"
807
808            [groups.a]
809            servers = ["shared"]
810
811            [groups.b]
812            servers = ["shared"]
813        "#;
814        let err = ForgeConfig::from_toml(toml).unwrap_err();
815        let msg = err.to_string();
816        assert!(msg.contains("shared"), "should mention server: {msg}");
817        assert!(
818            msg.contains("multiple groups"),
819            "should say multiple groups: {msg}"
820        );
821    }
822
823    #[test]
824    fn config_rejects_invalid_isolation_mode() {
825        let toml = r#"
826            [servers.test]
827            command = "test"
828            transport = "stdio"
829
830            [groups.bad]
831            servers = ["test"]
832            isolation = "paranoid"
833        "#;
834        let err = ForgeConfig::from_toml(toml).unwrap_err();
835        let msg = err.to_string();
836        assert!(msg.contains("paranoid"), "should mention mode: {msg}");
837    }
838
839    #[test]
840    fn config_parses_server_timeout() {
841        let toml = r#"
842            [servers.slow]
843            command = "slow-mcp"
844            transport = "stdio"
845            timeout_secs = 30
846        "#;
847
848        let config = ForgeConfig::from_toml(toml).unwrap();
849        assert_eq!(config.servers["slow"].timeout_secs, Some(30));
850    }
851
852    #[test]
853    fn config_server_timeout_defaults_to_none() {
854        let toml = r#"
855            [servers.fast]
856            command = "fast-mcp"
857            transport = "stdio"
858        "#;
859
860        let config = ForgeConfig::from_toml(toml).unwrap();
861        assert!(config.servers["fast"].timeout_secs.is_none());
862    }
863
864    #[test]
865    fn config_parses_circuit_breaker() {
866        let toml = r#"
867            [servers.flaky]
868            command = "flaky-mcp"
869            transport = "stdio"
870            circuit_breaker = true
871            failure_threshold = 5
872            recovery_timeout_secs = 60
873        "#;
874
875        let config = ForgeConfig::from_toml(toml).unwrap();
876        let flaky = &config.servers["flaky"];
877        assert_eq!(flaky.circuit_breaker, Some(true));
878        assert_eq!(flaky.failure_threshold, Some(5));
879        assert_eq!(flaky.recovery_timeout_secs, Some(60));
880    }
881
882    #[test]
883    fn config_circuit_breaker_defaults_to_none() {
884        let toml = r#"
885            [servers.stable]
886            command = "stable-mcp"
887            transport = "stdio"
888        "#;
889
890        let config = ForgeConfig::from_toml(toml).unwrap();
891        let stable = &config.servers["stable"];
892        assert!(stable.circuit_breaker.is_none());
893        assert!(stable.failure_threshold.is_none());
894        assert!(stable.recovery_timeout_secs.is_none());
895    }
896
897    #[test]
898    fn config_execution_mode_defaults_to_none() {
899        let toml = r#"
900            [sandbox]
901            timeout_secs = 5
902        "#;
903
904        let config = ForgeConfig::from_toml(toml).unwrap();
905        assert!(config.sandbox.execution_mode.is_none());
906    }
907
908    // --- v0.2 Config Validation Tests (CV-01..CV-07) ---
909
910    #[test]
911    fn cv01_max_resource_size_mb_range() {
912        // Valid (must fit within IPC limit — default 8 MB)
913        let toml = "[sandbox]\nmax_resource_size_mb = 7";
914        assert!(ForgeConfig::from_toml(toml).is_ok());
915
916        // Zero is invalid
917        let toml = "[sandbox]\nmax_resource_size_mb = 0";
918        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
919        assert!(err.contains("max_resource_size_mb"), "got: {err}");
920
921        // Over 512 is invalid
922        let toml = "[sandbox]\nmax_resource_size_mb = 513";
923        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
924        assert!(err.contains("max_resource_size_mb"), "got: {err}");
925    }
926
927    #[test]
928    fn cv02_max_parallel_range() {
929        // Valid: within default max_concurrent (8)
930        let toml = "[sandbox]\nmax_parallel = 4";
931        assert!(ForgeConfig::from_toml(toml).is_ok());
932
933        // Zero is invalid
934        let toml = "[sandbox]\nmax_parallel = 0";
935        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
936        assert!(err.contains("max_parallel"), "got: {err}");
937
938        // Exceeding max_concurrent is invalid
939        let toml = "[sandbox]\nmax_concurrent = 4\nmax_parallel = 5";
940        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
941        assert!(err.contains("max_parallel"), "got: {err}");
942    }
943
944    #[test]
945    fn cv03_stash_max_value_size_mb_range() {
946        // Valid
947        let toml = "[sandbox.stash]\nmax_value_size_mb = 16";
948        assert!(ForgeConfig::from_toml(toml).is_ok());
949
950        // Zero is invalid
951        let toml = "[sandbox.stash]\nmax_value_size_mb = 0";
952        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
953        assert!(err.contains("max_value_size_mb"), "got: {err}");
954
955        // Over 256 is invalid
956        let toml = "[sandbox.stash]\nmax_value_size_mb = 257";
957        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
958        assert!(err.contains("max_value_size_mb"), "got: {err}");
959    }
960
961    #[test]
962    fn cv04_stash_total_size_gte_value_size() {
963        // Valid: total >= value
964        let toml = "[sandbox.stash]\nmax_value_size_mb = 16\nmax_total_size_mb = 128";
965        assert!(ForgeConfig::from_toml(toml).is_ok());
966
967        // Invalid: total < value
968        let toml = "[sandbox.stash]\nmax_value_size_mb = 32\nmax_total_size_mb = 16";
969        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
970        assert!(err.contains("max_total_size_mb"), "got: {err}");
971    }
972
973    #[test]
974    fn cv05_stash_default_ttl_range() {
975        // Valid
976        let toml = "[sandbox.stash]\ndefault_ttl_secs = 3600";
977        assert!(ForgeConfig::from_toml(toml).is_ok());
978
979        // Zero is invalid
980        let toml = "[sandbox.stash]\ndefault_ttl_secs = 0";
981        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
982        assert!(err.contains("default_ttl_secs"), "got: {err}");
983
984        // Exceeding max_ttl is invalid
985        let toml = "[sandbox.stash]\ndefault_ttl_secs = 100000\nmax_ttl_secs = 86400";
986        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
987        assert!(err.contains("default_ttl_secs"), "got: {err}");
988    }
989
990    #[test]
991    fn cv06_stash_max_ttl_range() {
992        // Valid
993        let toml = "[sandbox.stash]\nmax_ttl_secs = 86400";
994        assert!(ForgeConfig::from_toml(toml).is_ok());
995
996        // Zero is invalid
997        let toml = "[sandbox.stash]\nmax_ttl_secs = 0";
998        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
999        assert!(err.contains("max_ttl_secs"), "got: {err}");
1000
1001        // Over 7 days is invalid
1002        let toml = "[sandbox.stash]\nmax_ttl_secs = 604801";
1003        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
1004        assert!(err.contains("max_ttl_secs"), "got: {err}");
1005    }
1006
1007    #[test]
1008    fn cv07_max_resource_size_fits_ipc() {
1009        // Valid: 7 MB + 1 MB overhead = 8 MB = fits default IPC limit
1010        let toml = "[sandbox]\nmax_resource_size_mb = 7";
1011        assert!(ForgeConfig::from_toml(toml).is_ok());
1012
1013        // Invalid: 8 MB + 1 MB overhead = 9 MB > 8 MB default IPC limit
1014        let toml = "[sandbox]\nmax_resource_size_mb = 8";
1015        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
1016        assert!(err.contains("IPC"), "got: {err}");
1017
1018        // Valid with explicit larger IPC limit
1019        let toml = "[sandbox]\nmax_resource_size_mb = 32\nmax_ipc_message_size_mb = 64";
1020        assert!(ForgeConfig::from_toml(toml).is_ok());
1021    }
1022
1023    #[test]
1024    fn config_parses_v02_sandbox_fields() {
1025        let toml = r#"
1026            [sandbox]
1027            max_resource_size_mb = 7
1028            max_ipc_message_size_mb = 64
1029            max_parallel = 4
1030
1031            [sandbox.stash]
1032            max_keys = 128
1033            max_value_size_mb = 8
1034            max_total_size_mb = 64
1035            default_ttl_secs = 1800
1036            max_ttl_secs = 43200
1037        "#;
1038
1039        let config = ForgeConfig::from_toml(toml).unwrap();
1040        assert_eq!(config.sandbox.max_resource_size_mb, Some(7));
1041        assert_eq!(config.sandbox.max_ipc_message_size_mb, Some(64));
1042        assert_eq!(config.sandbox.max_parallel, Some(4));
1043
1044        let stash = config.sandbox.stash.unwrap();
1045        assert_eq!(stash.max_keys, Some(128));
1046        assert_eq!(stash.max_value_size_mb, Some(8));
1047        assert_eq!(stash.max_total_size_mb, Some(64));
1048        assert_eq!(stash.default_ttl_secs, Some(1800));
1049        assert_eq!(stash.max_ttl_secs, Some(43200));
1050    }
1051
1052    // --- Pool config tests (CV-08 to CV-11) ---
1053
1054    #[test]
1055    fn cv_08_pool_max_workers_validation() {
1056        // max_workers = 0 is invalid
1057        let toml = r#"
1058            [sandbox.pool]
1059            enabled = true
1060            max_workers = 0
1061        "#;
1062        assert!(ForgeConfig::from_toml(toml).is_err());
1063
1064        // max_workers > max_concurrent is invalid
1065        let toml = r#"
1066            [sandbox]
1067            max_concurrent = 4
1068            [sandbox.pool]
1069            max_workers = 5
1070        "#;
1071        assert!(ForgeConfig::from_toml(toml).is_err());
1072
1073        // max_workers within range is valid
1074        let toml = r#"
1075            [sandbox]
1076            max_concurrent = 8
1077            [sandbox.pool]
1078            max_workers = 4
1079        "#;
1080        assert!(ForgeConfig::from_toml(toml).is_ok());
1081    }
1082
1083    #[test]
1084    fn cv_09_pool_min_workers_validation() {
1085        // min_workers > max_workers is invalid
1086        let toml = r#"
1087            [sandbox.pool]
1088            min_workers = 5
1089            max_workers = 2
1090        "#;
1091        assert!(ForgeConfig::from_toml(toml).is_err());
1092
1093        // min_workers <= max_workers is valid
1094        let toml = r#"
1095            [sandbox.pool]
1096            min_workers = 2
1097            max_workers = 4
1098        "#;
1099        assert!(ForgeConfig::from_toml(toml).is_ok());
1100    }
1101
1102    #[test]
1103    fn cv_10_pool_max_uses_validation() {
1104        // max_uses = 0 is invalid
1105        let toml = r#"
1106            [sandbox.pool]
1107            max_uses = 0
1108        "#;
1109        assert!(ForgeConfig::from_toml(toml).is_err());
1110
1111        // max_uses > 0 is valid
1112        let toml = r#"
1113            [sandbox.pool]
1114            max_uses = 100
1115        "#;
1116        assert!(ForgeConfig::from_toml(toml).is_ok());
1117    }
1118
1119    #[test]
1120    fn cv_11_pool_max_idle_validation() {
1121        // max_idle_secs < 5 is invalid
1122        let toml = r#"
1123            [sandbox.pool]
1124            max_idle_secs = 2
1125        "#;
1126        assert!(ForgeConfig::from_toml(toml).is_err());
1127
1128        // max_idle_secs > 3600 is invalid
1129        let toml = r#"
1130            [sandbox.pool]
1131            max_idle_secs = 7200
1132        "#;
1133        assert!(ForgeConfig::from_toml(toml).is_err());
1134
1135        // max_idle_secs = 60 is valid
1136        let toml = r#"
1137            [sandbox.pool]
1138            max_idle_secs = 60
1139        "#;
1140        assert!(ForgeConfig::from_toml(toml).is_ok());
1141    }
1142
1143    #[test]
1144    fn config_parses_pool_fields() {
1145        let toml = r#"
1146            [sandbox.pool]
1147            enabled = true
1148            min_workers = 2
1149            max_workers = 8
1150            max_idle_secs = 60
1151            max_uses = 50
1152        "#;
1153
1154        let config = ForgeConfig::from_toml(toml).unwrap();
1155        let pool = config.sandbox.pool.unwrap();
1156        assert_eq!(pool.enabled, Some(true));
1157        assert_eq!(pool.min_workers, Some(2));
1158        assert_eq!(pool.max_workers, Some(8));
1159        assert_eq!(pool.max_idle_secs, Some(60));
1160        assert_eq!(pool.max_uses, Some(50));
1161    }
1162
1163    // --- Production config parse tests (CFG-P01..CFG-P06) ---
1164
1165    fn load_production_example() -> ForgeConfig {
1166        let toml_str = include_str!("../../../forge.toml.example.production");
1167        ForgeConfig::from_toml(toml_str).expect("production example must parse")
1168    }
1169
1170    #[test]
1171    fn cfg_p01_production_example_parses() {
1172        let config = load_production_example();
1173        assert!(!config.servers.is_empty(), "should have servers");
1174    }
1175
1176    #[test]
1177    fn cfg_p02_production_pool_enabled() {
1178        let config = load_production_example();
1179        let pool = config.sandbox.pool.as_ref().expect("pool section required");
1180        assert_eq!(pool.enabled, Some(true));
1181        assert!(pool.min_workers.is_some());
1182        assert!(pool.max_workers.is_some());
1183    }
1184
1185    #[test]
1186    fn cfg_p03_production_strict_groups() {
1187        let config = load_production_example();
1188        assert!(!config.groups.is_empty(), "should have groups");
1189        let has_strict = config.groups.values().any(|g| g.isolation == "strict");
1190        assert!(has_strict, "should have at least one strict group");
1191    }
1192
1193    #[test]
1194    fn cfg_p04_production_stash_configured() {
1195        let config = load_production_example();
1196        let stash = config
1197            .sandbox
1198            .stash
1199            .as_ref()
1200            .expect("stash section required");
1201        assert!(stash.max_keys.is_some());
1202        assert!(stash.max_total_size_mb.is_some());
1203    }
1204
1205    #[test]
1206    fn cfg_p05_production_circuit_breakers() {
1207        let config = load_production_example();
1208        for (name, server) in &config.servers {
1209            assert_eq!(
1210                server.circuit_breaker,
1211                Some(true),
1212                "server '{}' should have circuit_breaker = true",
1213                name
1214            );
1215        }
1216    }
1217
1218    #[test]
1219    fn cfg_p06_production_execution_mode_child_process() {
1220        let config = load_production_example();
1221        assert_eq!(
1222            config.sandbox.execution_mode.as_deref(),
1223            Some("child_process")
1224        );
1225    }
1226
1227    /// Verify that `config-watch` feature is on by default (v0.4.0+).
1228    #[test]
1229    #[cfg(feature = "config-watch")]
1230    fn ff_d03_config_watch_is_default() {
1231        // config-watch is default-on since v0.4.0.
1232        // Verify the watcher module type is accessible.
1233        let _ = std::any::type_name::<crate::watcher::ConfigWatcher>();
1234    }
1235
1236    // --- Upgrade path compatibility tests (UP-01..UP-03) ---
1237
1238    #[test]
1239    fn up_01_v03x_config_without_pool_section() {
1240        // v0.3.x configs may not have [sandbox.pool] at all
1241        let toml = r#"
1242            [servers.test]
1243            command = "test-mcp"
1244            transport = "stdio"
1245
1246            [sandbox]
1247            timeout_secs = 5
1248        "#;
1249        let config = ForgeConfig::from_toml(toml).unwrap();
1250        assert!(config.sandbox.pool.is_none());
1251    }
1252
1253    #[test]
1254    fn up_02_v03x_config_without_manifest_section() {
1255        // v0.3.x configs may not have [manifest] at all
1256        let toml = r#"
1257            [servers.test]
1258            command = "test-mcp"
1259            transport = "stdio"
1260        "#;
1261        let config = ForgeConfig::from_toml(toml).unwrap();
1262        assert!(config.manifest.refresh_interval_secs.is_none());
1263    }
1264
1265    #[test]
1266    fn up_03_v03x_config_without_groups_or_stash() {
1267        // v0.3.x minimal config: just servers and sandbox basics
1268        let toml = r#"
1269            [servers.narsil]
1270            command = "narsil-mcp"
1271            args = ["--repos", "."]
1272            transport = "stdio"
1273
1274            [sandbox]
1275            timeout_secs = 5
1276            max_heap_mb = 64
1277            max_concurrent = 8
1278            max_tool_calls = 50
1279            execution_mode = "child_process"
1280        "#;
1281        let config = ForgeConfig::from_toml(toml).unwrap();
1282        assert!(config.groups.is_empty());
1283        assert!(config.sandbox.stash.is_none());
1284        assert!(config.sandbox.pool.is_none());
1285        assert_eq!(config.servers.len(), 1);
1286    }
1287
1288    // --- Startup concurrency tests (CV-12, SC-01..SC-02) ---
1289
1290    #[test]
1291    fn cv_12_startup_concurrency_validation() {
1292        // 0 is invalid
1293        let toml = "[sandbox]\nstartup_concurrency = 0";
1294        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
1295        assert!(err.contains("startup_concurrency"), "got: {err}");
1296
1297        // 65 is invalid
1298        let toml = "[sandbox]\nstartup_concurrency = 65";
1299        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
1300        assert!(err.contains("startup_concurrency"), "got: {err}");
1301
1302        // 1 is valid (sequential)
1303        let toml = "[sandbox]\nstartup_concurrency = 1";
1304        assert!(ForgeConfig::from_toml(toml).is_ok());
1305
1306        // 8 is valid (default)
1307        let toml = "[sandbox]\nstartup_concurrency = 8";
1308        assert!(ForgeConfig::from_toml(toml).is_ok());
1309
1310        // 64 is valid (max)
1311        let toml = "[sandbox]\nstartup_concurrency = 64";
1312        assert!(ForgeConfig::from_toml(toml).is_ok());
1313    }
1314
1315    #[test]
1316    fn default_startup_concurrency_is_at_least_one() {
1317        assert!(default_startup_concurrency() >= 1);
1318    }
1319
1320    #[test]
1321    fn sc_01_startup_concurrency_config_parses() {
1322        let toml = "[sandbox]\nstartup_concurrency = 4";
1323        let config = ForgeConfig::from_toml(toml).unwrap();
1324        assert_eq!(config.sandbox.startup_concurrency, Some(4));
1325    }
1326
1327    #[test]
1328    fn sc_02_startup_concurrency_defaults_to_none() {
1329        let toml = "[sandbox]\ntimeout_secs = 5";
1330        let config = ForgeConfig::from_toml(toml).unwrap();
1331        assert!(config.sandbox.startup_concurrency.is_none());
1332    }
1333
1334    /// Compile-time guard: ConfigError is #[non_exhaustive].
1335    #[test]
1336    #[allow(unreachable_patterns)]
1337    fn ne_config_error_is_non_exhaustive() {
1338        let err = ConfigError::Invalid("test".into());
1339        match err {
1340            ConfigError::Invalid(_) | ConfigError::Parse(_) => {}
1341            _ => {}
1342        }
1343    }
1344}