1#![warn(missing_docs)]
2
3#[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#[derive(Debug, Error)]
40#[non_exhaustive]
41pub enum ConfigError {
42 #[error("failed to read config file: {0}")]
44 Io(#[from] std::io::Error),
45
46 #[error("failed to parse config: {0}")]
48 Parse(#[from] toml::de::Error),
49
50 #[error("invalid config: {0}")]
52 Invalid(String),
53}
54
55#[derive(Debug, Clone, Deserialize)]
57pub struct ForgeConfig {
58 #[serde(default)]
60 pub servers: HashMap<String, ServerConfig>,
61
62 #[serde(default)]
64 pub sandbox: SandboxOverrides,
65
66 #[serde(default)]
68 pub groups: HashMap<String, GroupConfig>,
69
70 #[serde(default)]
72 pub manifest: ManifestConfig,
73}
74
75#[derive(Debug, Clone, Default, Deserialize)]
77pub struct ManifestConfig {
78 #[serde(default)]
81 pub refresh_interval_secs: Option<u64>,
82}
83
84#[derive(Debug, Clone, Deserialize)]
86pub struct GroupConfig {
87 pub servers: Vec<String>,
89
90 #[serde(default = "default_isolation")]
92 pub isolation: String,
93}
94
95fn default_isolation() -> String {
96 "open".to_string()
97}
98
99#[derive(Debug, Clone, Deserialize)]
101pub struct ServerConfig {
102 pub transport: String,
104
105 #[serde(default)]
107 pub command: Option<String>,
108
109 #[serde(default)]
111 pub args: Vec<String>,
112
113 #[serde(default)]
115 pub url: Option<String>,
116
117 #[serde(default)]
119 pub headers: HashMap<String, String>,
120
121 #[serde(default)]
123 pub description: Option<String>,
124
125 #[serde(default)]
127 pub timeout_secs: Option<u64>,
128
129 #[serde(default)]
131 pub circuit_breaker: Option<bool>,
132
133 #[serde(default)]
135 pub failure_threshold: Option<u32>,
136
137 #[serde(default)]
139 pub recovery_timeout_secs: Option<u64>,
140}
141
142#[derive(Debug, Clone, Default, Deserialize)]
144pub struct SandboxOverrides {
145 #[serde(default)]
147 pub timeout_secs: Option<u64>,
148
149 #[serde(default)]
151 pub max_heap_mb: Option<usize>,
152
153 #[serde(default)]
155 pub max_concurrent: Option<usize>,
156
157 #[serde(default)]
159 pub max_tool_calls: Option<usize>,
160
161 #[serde(default)]
163 pub execution_mode: Option<String>,
164
165 #[serde(default)]
167 pub max_ipc_message_size_mb: Option<usize>,
168
169 #[serde(default)]
171 pub max_resource_size_mb: Option<usize>,
172
173 #[serde(default)]
175 pub max_parallel: Option<usize>,
176
177 #[serde(default)]
179 pub stash: Option<StashOverrides>,
180
181 #[serde(default)]
183 pub pool: Option<PoolOverrides>,
184}
185
186#[derive(Debug, Clone, Default, Deserialize)]
191pub struct PoolOverrides {
192 #[serde(default)]
194 pub enabled: Option<bool>,
195
196 #[serde(default)]
198 pub min_workers: Option<usize>,
199
200 #[serde(default)]
202 pub max_workers: Option<usize>,
203
204 #[serde(default)]
206 pub max_idle_secs: Option<u64>,
207
208 #[serde(default)]
210 pub max_uses: Option<u32>,
211}
212
213#[derive(Debug, Clone, Default, Deserialize)]
215pub struct StashOverrides {
216 #[serde(default)]
218 pub max_keys: Option<usize>,
219
220 #[serde(default)]
222 pub max_value_size_mb: Option<usize>,
223
224 #[serde(default)]
226 pub max_total_size_mb: Option<usize>,
227
228 #[serde(default)]
230 pub default_ttl_secs: Option<u64>,
231
232 #[serde(default)]
234 pub max_ttl_secs: Option<u64>,
235
236 #[serde(default)]
238 pub max_calls: Option<usize>,
239}
240
241impl ForgeConfig {
242 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 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 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 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 let mut seen_servers: HashMap<&str, &str> = HashMap::new();
297 for (group_name, group_config) in &self.groups {
298 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 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 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 self.validate_sandbox_v2()?;
330
331 Ok(())
332 }
333
334 fn validate_sandbox_v2(&self) -> Result<(), ConfigError> {
335 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 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 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 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 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 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 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); 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 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 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 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 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 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
468fn 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(); 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 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 #[test]
884 fn cv01_max_resource_size_mb_range() {
885 let toml = "[sandbox]\nmax_resource_size_mb = 7";
887 assert!(ForgeConfig::from_toml(toml).is_ok());
888
889 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 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 let toml = "[sandbox]\nmax_parallel = 4";
904 assert!(ForgeConfig::from_toml(toml).is_ok());
905
906 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 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 let toml = "[sandbox.stash]\nmax_value_size_mb = 16";
921 assert!(ForgeConfig::from_toml(toml).is_ok());
922
923 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 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 let toml = "[sandbox.stash]\nmax_value_size_mb = 16\nmax_total_size_mb = 128";
938 assert!(ForgeConfig::from_toml(toml).is_ok());
939
940 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 let toml = "[sandbox.stash]\ndefault_ttl_secs = 3600";
950 assert!(ForgeConfig::from_toml(toml).is_ok());
951
952 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 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 let toml = "[sandbox.stash]\nmax_ttl_secs = 86400";
967 assert!(ForgeConfig::from_toml(toml).is_ok());
968
969 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 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 let toml = "[sandbox]\nmax_resource_size_mb = 7";
984 assert!(ForgeConfig::from_toml(toml).is_ok());
985
986 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 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 #[test]
1028 fn cv_08_pool_max_workers_validation() {
1029 let toml = r#"
1031 [sandbox.pool]
1032 enabled = true
1033 max_workers = 0
1034 "#;
1035 assert!(ForgeConfig::from_toml(toml).is_err());
1036
1037 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 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 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 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 let toml = r#"
1079 [sandbox.pool]
1080 max_uses = 0
1081 "#;
1082 assert!(ForgeConfig::from_toml(toml).is_err());
1083
1084 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 let toml = r#"
1096 [sandbox.pool]
1097 max_idle_secs = 2
1098 "#;
1099 assert!(ForgeConfig::from_toml(toml).is_err());
1100
1101 let toml = r#"
1103 [sandbox.pool]
1104 max_idle_secs = 7200
1105 "#;
1106 assert!(ForgeConfig::from_toml(toml).is_err());
1107
1108 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 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 #[test]
1202 #[cfg(feature = "config-watch")]
1203 fn ff_d03_config_watch_is_default() {
1204 let _ = std::any::type_name::<crate::watcher::ConfigWatcher>();
1207 }
1208
1209 #[test]
1212 fn up_01_v03x_config_without_pool_section() {
1213 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 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 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 #[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}