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
29use std::collections::HashMap;
30use std::path::Path;
31
32use serde::Deserialize;
33use thiserror::Error;
34
35/// Errors from config parsing.
36#[derive(Debug, Error)]
37pub enum ConfigError {
38    /// Failed to read config file.
39    #[error("failed to read config file: {0}")]
40    Io(#[from] std::io::Error),
41
42    /// Failed to parse TOML.
43    #[error("failed to parse config: {0}")]
44    Parse(#[from] toml::de::Error),
45
46    /// Invalid configuration value.
47    #[error("invalid config: {0}")]
48    Invalid(String),
49}
50
51/// Top-level Forge configuration.
52#[derive(Debug, Clone, Deserialize)]
53pub struct ForgeConfig {
54    /// Downstream MCP server configurations, keyed by server name.
55    #[serde(default)]
56    pub servers: HashMap<String, ServerConfig>,
57
58    /// Sandbox execution settings.
59    #[serde(default)]
60    pub sandbox: SandboxOverrides,
61
62    /// Server group definitions for cross-server data flow policies.
63    #[serde(default)]
64    pub groups: HashMap<String, GroupConfig>,
65}
66
67/// Configuration for a server group.
68#[derive(Debug, Clone, Deserialize)]
69pub struct GroupConfig {
70    /// Server names belonging to this group.
71    pub servers: Vec<String>,
72
73    /// Isolation mode: "strict" (no cross-group data flow) or "open" (unrestricted).
74    #[serde(default = "default_isolation")]
75    pub isolation: String,
76}
77
78fn default_isolation() -> String {
79    "open".to_string()
80}
81
82/// Configuration for a single downstream MCP server.
83#[derive(Debug, Clone, Deserialize)]
84pub struct ServerConfig {
85    /// Transport type: "stdio" or "sse".
86    pub transport: String,
87
88    /// Command to execute (stdio transport).
89    #[serde(default)]
90    pub command: Option<String>,
91
92    /// Command arguments (stdio transport).
93    #[serde(default)]
94    pub args: Vec<String>,
95
96    /// Server URL (sse transport).
97    #[serde(default)]
98    pub url: Option<String>,
99
100    /// HTTP headers (sse transport).
101    #[serde(default)]
102    pub headers: HashMap<String, String>,
103
104    /// Server description (optional, for manifest).
105    #[serde(default)]
106    pub description: Option<String>,
107
108    /// Per-server timeout in seconds for individual tool calls.
109    #[serde(default)]
110    pub timeout_secs: Option<u64>,
111
112    /// Enable circuit breaker for this server.
113    #[serde(default)]
114    pub circuit_breaker: Option<bool>,
115
116    /// Number of consecutive failures before opening the circuit (default: 3).
117    #[serde(default)]
118    pub failure_threshold: Option<u32>,
119
120    /// Seconds to wait before probing a tripped circuit (default: 30).
121    #[serde(default)]
122    pub recovery_timeout_secs: Option<u64>,
123}
124
125/// Sandbox configuration overrides.
126#[derive(Debug, Clone, Default, Deserialize)]
127pub struct SandboxOverrides {
128    /// Execution timeout in seconds.
129    #[serde(default)]
130    pub timeout_secs: Option<u64>,
131
132    /// Maximum V8 heap size in megabytes.
133    #[serde(default)]
134    pub max_heap_mb: Option<usize>,
135
136    /// Maximum concurrent sandbox executions.
137    #[serde(default)]
138    pub max_concurrent: Option<usize>,
139
140    /// Maximum tool calls per execution.
141    #[serde(default)]
142    pub max_tool_calls: Option<usize>,
143
144    /// Execution mode: "in_process" (default) or "child_process".
145    #[serde(default)]
146    pub execution_mode: Option<String>,
147
148    /// Maximum IPC message size in megabytes (default: 8 MB).
149    #[serde(default)]
150    pub max_ipc_message_size_mb: Option<usize>,
151
152    /// Maximum resource content size in megabytes (default: 64 MB).
153    #[serde(default)]
154    pub max_resource_size_mb: Option<usize>,
155
156    /// Maximum concurrent calls in forge.parallel() (default: 8).
157    #[serde(default)]
158    pub max_parallel: Option<usize>,
159
160    /// Stash configuration overrides.
161    #[serde(default)]
162    pub stash: Option<StashOverrides>,
163}
164
165/// Configuration overrides for the ephemeral stash.
166#[derive(Debug, Clone, Default, Deserialize)]
167pub struct StashOverrides {
168    /// Maximum number of stash entries per session.
169    #[serde(default)]
170    pub max_keys: Option<usize>,
171
172    /// Maximum size of a single stash value in megabytes.
173    #[serde(default)]
174    pub max_value_size_mb: Option<usize>,
175
176    /// Maximum total stash size in megabytes.
177    #[serde(default)]
178    pub max_total_size_mb: Option<usize>,
179
180    /// Default TTL for stash entries in seconds.
181    #[serde(default)]
182    pub default_ttl_secs: Option<u64>,
183
184    /// Maximum TTL for stash entries in seconds.
185    #[serde(default)]
186    pub max_ttl_secs: Option<u64>,
187}
188
189impl ForgeConfig {
190    /// Parse a config from a TOML string.
191    pub fn from_toml(toml_str: &str) -> Result<Self, ConfigError> {
192        let config: ForgeConfig = toml::from_str(toml_str)?;
193        config.validate()?;
194        Ok(config)
195    }
196
197    /// Load config from a file path.
198    pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
199        let content = std::fs::read_to_string(path)?;
200        Self::from_toml(&content)
201    }
202
203    /// Parse a config from a TOML string, expanding `${ENV_VAR}` references.
204    pub fn from_toml_with_env(toml_str: &str) -> Result<Self, ConfigError> {
205        let expanded = expand_env_vars(toml_str);
206        Self::from_toml(&expanded)
207    }
208
209    /// Load config from a file path, expanding environment variables.
210    pub fn from_file_with_env(path: &Path) -> Result<Self, ConfigError> {
211        let content = std::fs::read_to_string(path)?;
212        Self::from_toml_with_env(&content)
213    }
214
215    fn validate(&self) -> Result<(), ConfigError> {
216        for (name, server) in &self.servers {
217            match server.transport.as_str() {
218                "stdio" => {
219                    if server.command.is_none() {
220                        return Err(ConfigError::Invalid(format!(
221                            "server '{}': stdio transport requires 'command'",
222                            name
223                        )));
224                    }
225                }
226                "sse" => {
227                    if server.url.is_none() {
228                        return Err(ConfigError::Invalid(format!(
229                            "server '{}': sse transport requires 'url'",
230                            name
231                        )));
232                    }
233                }
234                other => {
235                    return Err(ConfigError::Invalid(format!(
236                        "server '{}': unsupported transport '{}', supported: stdio, sse",
237                        name, other
238                    )));
239                }
240            }
241        }
242
243        // Validate groups
244        let mut seen_servers: HashMap<&str, &str> = HashMap::new();
245        for (group_name, group_config) in &self.groups {
246            // Validate isolation mode
247            match group_config.isolation.as_str() {
248                "strict" | "open" => {}
249                other => {
250                    return Err(ConfigError::Invalid(format!(
251                        "group '{}': unsupported isolation '{}', supported: strict, open",
252                        group_name, other
253                    )));
254                }
255            }
256
257            for server_ref in &group_config.servers {
258                // Check server exists
259                if !self.servers.contains_key(server_ref) {
260                    return Err(ConfigError::Invalid(format!(
261                        "group '{}': references unknown server '{}'",
262                        group_name, server_ref
263                    )));
264                }
265                // Check no server in multiple groups
266                if let Some(existing_group) = seen_servers.get(server_ref.as_str()) {
267                    return Err(ConfigError::Invalid(format!(
268                        "server '{}' is in multiple groups: '{}' and '{}'",
269                        server_ref, existing_group, group_name
270                    )));
271                }
272                seen_servers.insert(server_ref, group_name);
273            }
274        }
275
276        // Validate sandbox v0.2 fields
277        self.validate_sandbox_v2()?;
278
279        Ok(())
280    }
281
282    fn validate_sandbox_v2(&self) -> Result<(), ConfigError> {
283        // CV-01: max_resource_size_mb must be > 0 and <= 512
284        if let Some(size) = self.sandbox.max_resource_size_mb {
285            if size == 0 || size > 512 {
286                return Err(ConfigError::Invalid(
287                    "sandbox.max_resource_size_mb must be > 0 and <= 512".into(),
288                ));
289            }
290        }
291
292        // CV-02: max_parallel must be >= 1 and <= max_concurrent (or default 8)
293        if let Some(parallel) = self.sandbox.max_parallel {
294            let max_concurrent = self.sandbox.max_concurrent.unwrap_or(8);
295            if parallel < 1 || parallel > max_concurrent {
296                return Err(ConfigError::Invalid(format!(
297                    "sandbox.max_parallel must be >= 1 and <= max_concurrent ({})",
298                    max_concurrent
299                )));
300            }
301        }
302
303        if let Some(ref stash) = self.sandbox.stash {
304            // CV-03: stash.max_value_size_mb must be > 0 and <= 256
305            if let Some(size) = stash.max_value_size_mb {
306                if size == 0 || size > 256 {
307                    return Err(ConfigError::Invalid(
308                        "sandbox.stash.max_value_size_mb must be > 0 and <= 256".into(),
309                    ));
310                }
311            }
312
313            // CV-04: stash.max_total_size_mb must be >= stash.max_value_size_mb
314            if let (Some(total), Some(value)) = (stash.max_total_size_mb, stash.max_value_size_mb) {
315                if total < value {
316                    return Err(ConfigError::Invalid(
317                        "sandbox.stash.max_total_size_mb must be >= sandbox.stash.max_value_size_mb"
318                            .into(),
319                    ));
320                }
321            }
322
323            // CV-05: stash.default_ttl_secs must be > 0 and <= stash.max_ttl_secs
324            if let Some(default_ttl) = stash.default_ttl_secs {
325                if default_ttl == 0 {
326                    return Err(ConfigError::Invalid(
327                        "sandbox.stash.default_ttl_secs must be > 0".into(),
328                    ));
329                }
330                let max_ttl = stash.max_ttl_secs.unwrap_or(86400);
331                if default_ttl > max_ttl {
332                    return Err(ConfigError::Invalid(format!(
333                        "sandbox.stash.default_ttl_secs ({}) must be <= max_ttl_secs ({})",
334                        default_ttl, max_ttl
335                    )));
336                }
337            }
338
339            // CV-06: stash.max_ttl_secs must be > 0 and <= 604800 (7 days)
340            if let Some(max_ttl) = stash.max_ttl_secs {
341                if max_ttl == 0 || max_ttl > 604800 {
342                    return Err(ConfigError::Invalid(
343                        "sandbox.stash.max_ttl_secs must be > 0 and <= 604800 (7 days)".into(),
344                    ));
345                }
346            }
347        }
348
349        // CV-07: max_resource_size_mb + 1 must fit within IPC message size
350        // In child_process mode, resource content flows over IPC
351        if let Some(resource_mb) = self.sandbox.max_resource_size_mb {
352            let ipc_limit_mb = self.sandbox.max_ipc_message_size_mb.unwrap_or(8); // default 8 MB
353            if resource_mb + 1 > ipc_limit_mb {
354                return Err(ConfigError::Invalid(format!(
355                    "sandbox.max_resource_size_mb ({}) + 1 MB overhead exceeds IPC message limit ({} MB)",
356                    resource_mb, ipc_limit_mb
357                )));
358            }
359        }
360
361        Ok(())
362    }
363}
364
365/// Expand `${ENV_VAR}` patterns in a string using environment variables.
366fn expand_env_vars(input: &str) -> String {
367    let mut result = String::with_capacity(input.len());
368    let mut chars = input.chars().peekable();
369
370    while let Some(ch) = chars.next() {
371        if ch == '$' && chars.peek() == Some(&'{') {
372            chars.next(); // consume '{'
373            let mut var_name = String::new();
374            for c in chars.by_ref() {
375                if c == '}' {
376                    break;
377                }
378                var_name.push(c);
379            }
380            match std::env::var(&var_name) {
381                Ok(value) => result.push_str(&value),
382                Err(_) => {
383                    // Leave the placeholder if env var not found
384                    result.push_str(&format!("${{{}}}", var_name));
385                }
386            }
387        } else {
388            result.push(ch);
389        }
390    }
391
392    result
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn config_parses_minimal_toml() {
401        let toml = r#"
402            [servers.narsil]
403            command = "narsil-mcp"
404            transport = "stdio"
405        "#;
406
407        let config = ForgeConfig::from_toml(toml).unwrap();
408        assert_eq!(config.servers.len(), 1);
409        let narsil = &config.servers["narsil"];
410        assert_eq!(narsil.transport, "stdio");
411        assert_eq!(narsil.command.as_deref(), Some("narsil-mcp"));
412    }
413
414    #[test]
415    fn config_parses_sse_server() {
416        let toml = r#"
417            [servers.github]
418            url = "https://mcp.github.com/sse"
419            transport = "sse"
420        "#;
421
422        let config = ForgeConfig::from_toml(toml).unwrap();
423        let github = &config.servers["github"];
424        assert_eq!(github.transport, "sse");
425        assert_eq!(github.url.as_deref(), Some("https://mcp.github.com/sse"));
426    }
427
428    #[test]
429    fn config_parses_sandbox_overrides() {
430        let toml = r#"
431            [sandbox]
432            timeout_secs = 10
433            max_heap_mb = 128
434            max_concurrent = 4
435            max_tool_calls = 100
436        "#;
437
438        let config = ForgeConfig::from_toml(toml).unwrap();
439        assert_eq!(config.sandbox.timeout_secs, Some(10));
440        assert_eq!(config.sandbox.max_heap_mb, Some(128));
441        assert_eq!(config.sandbox.max_concurrent, Some(4));
442        assert_eq!(config.sandbox.max_tool_calls, Some(100));
443    }
444
445    #[test]
446    fn config_expands_environment_variables() {
447        temp_env::with_var("FORGE_TEST_TOKEN", Some("secret123"), || {
448            let toml = r#"
449                [servers.github]
450                url = "https://mcp.github.com/sse"
451                transport = "sse"
452                headers = { Authorization = "Bearer ${FORGE_TEST_TOKEN}" }
453            "#;
454
455            let config = ForgeConfig::from_toml_with_env(toml).unwrap();
456            let github = &config.servers["github"];
457            assert_eq!(
458                github.headers.get("Authorization").unwrap(),
459                "Bearer secret123"
460            );
461        });
462    }
463
464    #[test]
465    fn config_rejects_invalid_transport() {
466        let toml = r#"
467            [servers.test]
468            command = "test"
469            transport = "grpc"
470        "#;
471
472        let err = ForgeConfig::from_toml(toml).unwrap_err();
473        let msg = err.to_string();
474        assert!(
475            msg.contains("grpc"),
476            "error should mention the transport: {msg}"
477        );
478        assert!(
479            msg.contains("stdio"),
480            "error should mention supported transports: {msg}"
481        );
482    }
483
484    #[test]
485    fn config_rejects_stdio_without_command() {
486        let toml = r#"
487            [servers.test]
488            transport = "stdio"
489        "#;
490
491        let err = ForgeConfig::from_toml(toml).unwrap_err();
492        assert!(err.to_string().contains("command"));
493    }
494
495    #[test]
496    fn config_rejects_sse_without_url() {
497        let toml = r#"
498            [servers.test]
499            transport = "sse"
500        "#;
501
502        let err = ForgeConfig::from_toml(toml).unwrap_err();
503        assert!(err.to_string().contains("url"));
504    }
505
506    #[test]
507    fn config_loads_from_file() {
508        let dir = std::env::temp_dir().join("forge-config-test");
509        std::fs::create_dir_all(&dir).unwrap();
510        let path = dir.join("forge.toml");
511        std::fs::write(
512            &path,
513            r#"
514            [servers.test]
515            command = "test-server"
516            transport = "stdio"
517        "#,
518        )
519        .unwrap();
520
521        let config = ForgeConfig::from_file(&path).unwrap();
522        assert_eq!(config.servers.len(), 1);
523        assert_eq!(
524            config.servers["test"].command.as_deref(),
525            Some("test-server")
526        );
527
528        std::fs::remove_dir_all(&dir).ok();
529    }
530
531    #[test]
532    fn config_uses_defaults_when_absent() {
533        let toml = r#"
534            [servers.test]
535            command = "test"
536            transport = "stdio"
537        "#;
538
539        let config = ForgeConfig::from_toml(toml).unwrap();
540        assert!(config.sandbox.timeout_secs.is_none());
541        assert!(config.sandbox.max_heap_mb.is_none());
542        assert!(config.sandbox.max_concurrent.is_none());
543        assert!(config.sandbox.max_tool_calls.is_none());
544    }
545
546    #[test]
547    fn config_parses_full_example() {
548        let toml = r#"
549            [servers.narsil]
550            command = "narsil-mcp"
551            args = ["--repos", ".", "--streaming"]
552            transport = "stdio"
553            description = "Code intelligence"
554
555            [servers.github]
556            url = "https://mcp.github.com/sse"
557            transport = "sse"
558            headers = { Authorization = "Bearer token123" }
559
560            [sandbox]
561            timeout_secs = 5
562            max_heap_mb = 64
563            max_concurrent = 8
564            max_tool_calls = 50
565        "#;
566
567        let config = ForgeConfig::from_toml(toml).unwrap();
568        assert_eq!(config.servers.len(), 2);
569
570        let narsil = &config.servers["narsil"];
571        assert_eq!(narsil.command.as_deref(), Some("narsil-mcp"));
572        assert_eq!(narsil.args, vec!["--repos", ".", "--streaming"]);
573        assert_eq!(narsil.description.as_deref(), Some("Code intelligence"));
574
575        let github = &config.servers["github"];
576        assert_eq!(github.url.as_deref(), Some("https://mcp.github.com/sse"));
577        assert_eq!(
578            github.headers.get("Authorization").unwrap(),
579            "Bearer token123"
580        );
581
582        assert_eq!(config.sandbox.timeout_secs, Some(5));
583    }
584
585    #[test]
586    fn config_empty_servers_is_valid() {
587        let toml = "";
588        let config = ForgeConfig::from_toml(toml).unwrap();
589        assert!(config.servers.is_empty());
590    }
591
592    #[test]
593    fn env_var_expansion_preserves_unresolved() {
594        let result = expand_env_vars("prefix ${DEFINITELY_NOT_SET_12345} suffix");
595        assert_eq!(result, "prefix ${DEFINITELY_NOT_SET_12345} suffix");
596    }
597
598    #[test]
599    fn env_var_expansion_handles_no_vars() {
600        let result = expand_env_vars("no variables here");
601        assert_eq!(result, "no variables here");
602    }
603
604    #[test]
605    fn config_parses_execution_mode_child_process() {
606        let toml = r#"
607            [sandbox]
608            execution_mode = "child_process"
609        "#;
610
611        let config = ForgeConfig::from_toml(toml).unwrap();
612        assert_eq!(
613            config.sandbox.execution_mode.as_deref(),
614            Some("child_process")
615        );
616    }
617
618    #[test]
619    fn config_parses_groups() {
620        let toml = r#"
621            [servers.vault]
622            command = "vault-mcp"
623            transport = "stdio"
624
625            [servers.slack]
626            command = "slack-mcp"
627            transport = "stdio"
628
629            [groups.internal]
630            servers = ["vault"]
631            isolation = "strict"
632
633            [groups.external]
634            servers = ["slack"]
635            isolation = "open"
636        "#;
637
638        let config = ForgeConfig::from_toml(toml).unwrap();
639        assert_eq!(config.groups.len(), 2);
640        assert_eq!(config.groups["internal"].isolation, "strict");
641        assert_eq!(config.groups["external"].servers, vec!["slack"]);
642    }
643
644    #[test]
645    fn config_groups_default_to_empty() {
646        let toml = r#"
647            [servers.test]
648            command = "test"
649            transport = "stdio"
650        "#;
651        let config = ForgeConfig::from_toml(toml).unwrap();
652        assert!(config.groups.is_empty());
653    }
654
655    #[test]
656    fn config_rejects_group_with_unknown_server() {
657        let toml = r#"
658            [servers.real]
659            command = "real"
660            transport = "stdio"
661
662            [groups.bad]
663            servers = ["nonexistent"]
664        "#;
665        let err = ForgeConfig::from_toml(toml).unwrap_err();
666        let msg = err.to_string();
667        assert!(msg.contains("nonexistent"), "should mention server: {msg}");
668        assert!(msg.contains("unknown"), "should say unknown: {msg}");
669    }
670
671    #[test]
672    fn config_rejects_server_in_multiple_groups() {
673        let toml = r#"
674            [servers.shared]
675            command = "shared"
676            transport = "stdio"
677
678            [groups.a]
679            servers = ["shared"]
680
681            [groups.b]
682            servers = ["shared"]
683        "#;
684        let err = ForgeConfig::from_toml(toml).unwrap_err();
685        let msg = err.to_string();
686        assert!(msg.contains("shared"), "should mention server: {msg}");
687        assert!(
688            msg.contains("multiple groups"),
689            "should say multiple groups: {msg}"
690        );
691    }
692
693    #[test]
694    fn config_rejects_invalid_isolation_mode() {
695        let toml = r#"
696            [servers.test]
697            command = "test"
698            transport = "stdio"
699
700            [groups.bad]
701            servers = ["test"]
702            isolation = "paranoid"
703        "#;
704        let err = ForgeConfig::from_toml(toml).unwrap_err();
705        let msg = err.to_string();
706        assert!(msg.contains("paranoid"), "should mention mode: {msg}");
707    }
708
709    #[test]
710    fn config_parses_server_timeout() {
711        let toml = r#"
712            [servers.slow]
713            command = "slow-mcp"
714            transport = "stdio"
715            timeout_secs = 30
716        "#;
717
718        let config = ForgeConfig::from_toml(toml).unwrap();
719        assert_eq!(config.servers["slow"].timeout_secs, Some(30));
720    }
721
722    #[test]
723    fn config_server_timeout_defaults_to_none() {
724        let toml = r#"
725            [servers.fast]
726            command = "fast-mcp"
727            transport = "stdio"
728        "#;
729
730        let config = ForgeConfig::from_toml(toml).unwrap();
731        assert!(config.servers["fast"].timeout_secs.is_none());
732    }
733
734    #[test]
735    fn config_parses_circuit_breaker() {
736        let toml = r#"
737            [servers.flaky]
738            command = "flaky-mcp"
739            transport = "stdio"
740            circuit_breaker = true
741            failure_threshold = 5
742            recovery_timeout_secs = 60
743        "#;
744
745        let config = ForgeConfig::from_toml(toml).unwrap();
746        let flaky = &config.servers["flaky"];
747        assert_eq!(flaky.circuit_breaker, Some(true));
748        assert_eq!(flaky.failure_threshold, Some(5));
749        assert_eq!(flaky.recovery_timeout_secs, Some(60));
750    }
751
752    #[test]
753    fn config_circuit_breaker_defaults_to_none() {
754        let toml = r#"
755            [servers.stable]
756            command = "stable-mcp"
757            transport = "stdio"
758        "#;
759
760        let config = ForgeConfig::from_toml(toml).unwrap();
761        let stable = &config.servers["stable"];
762        assert!(stable.circuit_breaker.is_none());
763        assert!(stable.failure_threshold.is_none());
764        assert!(stable.recovery_timeout_secs.is_none());
765    }
766
767    #[test]
768    fn config_execution_mode_defaults_to_none() {
769        let toml = r#"
770            [sandbox]
771            timeout_secs = 5
772        "#;
773
774        let config = ForgeConfig::from_toml(toml).unwrap();
775        assert!(config.sandbox.execution_mode.is_none());
776    }
777
778    // --- v0.2 Config Validation Tests (CV-01..CV-07) ---
779
780    #[test]
781    fn cv01_max_resource_size_mb_range() {
782        // Valid (must fit within IPC limit — default 8 MB)
783        let toml = "[sandbox]\nmax_resource_size_mb = 7";
784        assert!(ForgeConfig::from_toml(toml).is_ok());
785
786        // Zero is invalid
787        let toml = "[sandbox]\nmax_resource_size_mb = 0";
788        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
789        assert!(err.contains("max_resource_size_mb"), "got: {err}");
790
791        // Over 512 is invalid
792        let toml = "[sandbox]\nmax_resource_size_mb = 513";
793        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
794        assert!(err.contains("max_resource_size_mb"), "got: {err}");
795    }
796
797    #[test]
798    fn cv02_max_parallel_range() {
799        // Valid: within default max_concurrent (8)
800        let toml = "[sandbox]\nmax_parallel = 4";
801        assert!(ForgeConfig::from_toml(toml).is_ok());
802
803        // Zero is invalid
804        let toml = "[sandbox]\nmax_parallel = 0";
805        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
806        assert!(err.contains("max_parallel"), "got: {err}");
807
808        // Exceeding max_concurrent is invalid
809        let toml = "[sandbox]\nmax_concurrent = 4\nmax_parallel = 5";
810        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
811        assert!(err.contains("max_parallel"), "got: {err}");
812    }
813
814    #[test]
815    fn cv03_stash_max_value_size_mb_range() {
816        // Valid
817        let toml = "[sandbox.stash]\nmax_value_size_mb = 16";
818        assert!(ForgeConfig::from_toml(toml).is_ok());
819
820        // Zero is invalid
821        let toml = "[sandbox.stash]\nmax_value_size_mb = 0";
822        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
823        assert!(err.contains("max_value_size_mb"), "got: {err}");
824
825        // Over 256 is invalid
826        let toml = "[sandbox.stash]\nmax_value_size_mb = 257";
827        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
828        assert!(err.contains("max_value_size_mb"), "got: {err}");
829    }
830
831    #[test]
832    fn cv04_stash_total_size_gte_value_size() {
833        // Valid: total >= value
834        let toml = "[sandbox.stash]\nmax_value_size_mb = 16\nmax_total_size_mb = 128";
835        assert!(ForgeConfig::from_toml(toml).is_ok());
836
837        // Invalid: total < value
838        let toml = "[sandbox.stash]\nmax_value_size_mb = 32\nmax_total_size_mb = 16";
839        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
840        assert!(err.contains("max_total_size_mb"), "got: {err}");
841    }
842
843    #[test]
844    fn cv05_stash_default_ttl_range() {
845        // Valid
846        let toml = "[sandbox.stash]\ndefault_ttl_secs = 3600";
847        assert!(ForgeConfig::from_toml(toml).is_ok());
848
849        // Zero is invalid
850        let toml = "[sandbox.stash]\ndefault_ttl_secs = 0";
851        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
852        assert!(err.contains("default_ttl_secs"), "got: {err}");
853
854        // Exceeding max_ttl is invalid
855        let toml = "[sandbox.stash]\ndefault_ttl_secs = 100000\nmax_ttl_secs = 86400";
856        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
857        assert!(err.contains("default_ttl_secs"), "got: {err}");
858    }
859
860    #[test]
861    fn cv06_stash_max_ttl_range() {
862        // Valid
863        let toml = "[sandbox.stash]\nmax_ttl_secs = 86400";
864        assert!(ForgeConfig::from_toml(toml).is_ok());
865
866        // Zero is invalid
867        let toml = "[sandbox.stash]\nmax_ttl_secs = 0";
868        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
869        assert!(err.contains("max_ttl_secs"), "got: {err}");
870
871        // Over 7 days is invalid
872        let toml = "[sandbox.stash]\nmax_ttl_secs = 604801";
873        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
874        assert!(err.contains("max_ttl_secs"), "got: {err}");
875    }
876
877    #[test]
878    fn cv07_max_resource_size_fits_ipc() {
879        // Valid: 7 MB + 1 MB overhead = 8 MB = fits default IPC limit
880        let toml = "[sandbox]\nmax_resource_size_mb = 7";
881        assert!(ForgeConfig::from_toml(toml).is_ok());
882
883        // Invalid: 8 MB + 1 MB overhead = 9 MB > 8 MB default IPC limit
884        let toml = "[sandbox]\nmax_resource_size_mb = 8";
885        let err = ForgeConfig::from_toml(toml).unwrap_err().to_string();
886        assert!(err.contains("IPC"), "got: {err}");
887
888        // Valid with explicit larger IPC limit
889        let toml = "[sandbox]\nmax_resource_size_mb = 32\nmax_ipc_message_size_mb = 64";
890        assert!(ForgeConfig::from_toml(toml).is_ok());
891    }
892
893    #[test]
894    fn config_parses_v02_sandbox_fields() {
895        let toml = r#"
896            [sandbox]
897            max_resource_size_mb = 7
898            max_ipc_message_size_mb = 64
899            max_parallel = 4
900
901            [sandbox.stash]
902            max_keys = 128
903            max_value_size_mb = 8
904            max_total_size_mb = 64
905            default_ttl_secs = 1800
906            max_ttl_secs = 43200
907        "#;
908
909        let config = ForgeConfig::from_toml(toml).unwrap();
910        assert_eq!(config.sandbox.max_resource_size_mb, Some(7));
911        assert_eq!(config.sandbox.max_ipc_message_size_mb, Some(64));
912        assert_eq!(config.sandbox.max_parallel, Some(4));
913
914        let stash = config.sandbox.stash.unwrap();
915        assert_eq!(stash.max_keys, Some(128));
916        assert_eq!(stash.max_value_size_mb, Some(8));
917        assert_eq!(stash.max_total_size_mb, Some(64));
918        assert_eq!(stash.default_ttl_secs, Some(1800));
919        assert_eq!(stash.max_ttl_secs, Some(43200));
920    }
921}