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