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