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