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