1use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::path::{Path, PathBuf};
11
12use perspt_sdk::{EffectKind, FootprintSpec, ProposalBinding, RiskClass};
13
14const MASKED_API_KEY: &str = "***";
16
17#[derive(Debug, Clone, Default, Serialize, Deserialize)]
24#[serde(default)]
25pub struct ProviderEntry {
26 #[serde(skip_serializing_if = "Option::is_none")]
30 pub adapter: Option<String>,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub api_key_env: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub api_key: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub base_url: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub project_id: Option<String>,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub location: Option<String>,
46}
47
48impl ProviderEntry {
49 pub fn adapter_or<'a>(&'a self, key: &'a str) -> &'a str {
51 self.adapter.as_deref().unwrap_or(key)
52 }
53}
54
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
59#[serde(default)]
60pub struct ModelsConfig {
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub architect: Option<String>,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub actuator: Option<String>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub verifier: Option<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub speculator: Option<String>,
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub adjudicator: Option<String>,
72 #[serde(skip_serializing_if = "Option::is_none")]
76 pub turn_timeout_secs: Option<u64>,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum ExternalToolMode {
83 Agent,
84 Chat,
85}
86
87fn default_external_modes() -> Vec<ExternalToolMode> {
88 vec![ExternalToolMode::Agent]
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "snake_case")]
94pub enum ExternalToolTransport {
95 Stdio,
96 StreamableHttp,
97}
98
99fn default_external_timeout_ms() -> u64 {
100 30_000
101}
102
103fn default_external_result_bytes() -> usize {
104 1_048_576
105}
106
107fn default_external_stderr_bytes() -> usize {
108 65_536
109}
110
111fn default_subscriptions() -> bool {
112 true
113}
114
115fn default_tasks() -> bool {
116 true
117}
118
119fn default_task_wait_ms() -> u64 {
120 300_000
121}
122
123fn default_sampling_tokens() -> u32 {
124 4096
125}
126
127#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(default)]
131pub struct McpRootConfig {
132 pub uri: String,
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub name: Option<String>,
135}
136
137#[derive(Debug, Clone, Default, Serialize, Deserialize)]
139#[serde(default)]
140pub struct ExternalToolPolicy {
141 pub effect: Option<EffectKind>,
143 pub risk: Option<RiskClass>,
144 pub footprint: Option<FootprintSpec>,
146 pub proposal_bindings: Vec<ProposalBinding>,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
152#[serde(default)]
153pub struct ExternalToolConfig {
154 pub id: String,
155 pub transport: ExternalToolTransport,
156 pub command: Vec<String>,
158 pub url: Option<String>,
159 pub env_from_env: BTreeMap<String, String>,
161 pub headers_from_env: BTreeMap<String, String>,
163 #[serde(default = "default_external_timeout_ms")]
164 pub timeout_ms: u64,
165 #[serde(default = "default_external_result_bytes")]
166 pub max_result_bytes: usize,
167 #[serde(default = "default_external_stderr_bytes")]
168 pub max_stderr_bytes: usize,
169 #[serde(default = "default_external_modes")]
170 pub modes: Vec<ExternalToolMode>,
171 pub roots: Vec<McpRootConfig>,
174 pub sampling: bool,
177 pub elicitation: bool,
180 #[serde(default = "default_subscriptions")]
183 pub subscriptions: bool,
184 pub resource_subscriptions: Vec<String>,
185 #[serde(default = "default_tasks")]
187 pub tasks: bool,
188 #[serde(default = "default_task_wait_ms")]
191 pub max_task_wait_ms: u64,
192 #[serde(default = "default_sampling_tokens")]
194 pub max_sampling_tokens: u32,
195 pub tools: BTreeMap<String, ExternalToolPolicy>,
196}
197
198impl Default for ExternalToolConfig {
199 fn default() -> Self {
200 Self {
201 id: String::new(),
202 transport: ExternalToolTransport::Stdio,
203 command: Vec::new(),
204 url: None,
205 env_from_env: BTreeMap::new(),
206 headers_from_env: BTreeMap::new(),
207 timeout_ms: default_external_timeout_ms(),
208 max_result_bytes: default_external_result_bytes(),
209 max_stderr_bytes: default_external_stderr_bytes(),
210 modes: default_external_modes(),
211 roots: Vec::new(),
212 sampling: false,
213 elicitation: false,
214 subscriptions: default_subscriptions(),
215 resource_subscriptions: Vec::new(),
216 tasks: default_tasks(),
217 max_task_wait_ms: default_task_wait_ms(),
218 max_sampling_tokens: default_sampling_tokens(),
219 tools: BTreeMap::new(),
220 }
221 }
222}
223
224impl ExternalToolConfig {
225 pub fn supports(&self, mode: ExternalToolMode) -> bool {
226 self.modes.contains(&mode)
227 }
228
229 fn validate(&self) -> Result<()> {
230 anyhow::ensure!(
231 !self.id.is_empty()
232 && self.id.chars().all(|character| {
233 character.is_ascii_alphanumeric() || matches!(character, '-' | '_')
234 }),
235 "external tool server id {:?} must contain only ASCII letters, digits, '-' or '_'",
236 self.id
237 );
238 anyhow::ensure!(
239 self.timeout_ms > 0,
240 "external tool {:?}: timeout_ms must be positive",
241 self.id
242 );
243 anyhow::ensure!(
244 self.max_result_bytes > 0 && self.max_stderr_bytes > 0,
245 "external tool {:?}: result and stderr caps must be positive",
246 self.id
247 );
248 self.validate_mcp()?;
249 anyhow::ensure!(
250 !self.modes.is_empty(),
251 "external tool {:?}: modes cannot be empty",
252 self.id
253 );
254 let unique_modes: std::collections::BTreeSet<_> =
255 self.modes.iter().map(|mode| format!("{mode:?}")).collect();
256 anyhow::ensure!(
257 unique_modes.len() == self.modes.len(),
258 "external tool {:?}: duplicate mode",
259 self.id
260 );
261 match self.transport {
262 ExternalToolTransport::Stdio => anyhow::ensure!(
263 !self.command.is_empty() && self.url.is_none(),
264 "external tool {:?}: stdio requires command argv and forbids url",
265 self.id
266 ),
267 ExternalToolTransport::StreamableHttp => anyhow::ensure!(
268 self.command.is_empty() && self.url.is_some(),
269 "external tool {:?}: Streamable HTTP requires url and forbids command",
270 self.id
271 ),
272 }
273 Ok(())
274 }
275
276 fn validate_mcp(&self) -> Result<()> {
277 anyhow::ensure!(
278 self.max_sampling_tokens > 0,
279 "external tool {:?}: max_sampling_tokens must be positive",
280 self.id
281 );
282 anyhow::ensure!(
283 self.max_task_wait_ms > 0,
284 "external tool {:?}: max_task_wait_ms must be positive",
285 self.id
286 );
287 for root in &self.roots {
288 let uri = url::Url::parse(&root.uri)
289 .with_context(|| format!("external tool {:?}: invalid MCP root URI", self.id))?;
290 anyhow::ensure!(
291 uri.scheme() == "file" && uri.path().starts_with('/'),
292 "external tool {:?}: MCP roots must be absolute file:// URIs",
293 self.id
294 );
295 }
296 for uri in &self.resource_subscriptions {
297 anyhow::ensure!(
298 url::Url::parse(uri).is_ok(),
299 "external tool {:?}: invalid resource subscription URI {uri:?}",
300 self.id
301 );
302 }
303 Ok(())
304 }
305}
306
307#[derive(Debug, Clone, Default, Serialize, Deserialize)]
313#[serde(default)]
314pub struct Config {
315 #[serde(
317 alias = "provider_type",
318 alias = "default_provider",
319 skip_serializing_if = "Option::is_none"
320 )]
321 pub provider: Option<String>,
322
323 #[serde(alias = "default_model", skip_serializing_if = "Option::is_none")]
325 pub model: Option<String>,
326
327 #[serde(skip_serializing_if = "Option::is_none")]
329 pub api_key: Option<String>,
330
331 #[serde(skip_serializing_if = "Option::is_none")]
333 pub base_url: Option<String>,
334
335 #[serde(skip_serializing_if = "Option::is_none")]
338 pub vertex_project_id: Option<String>,
339
340 #[serde(skip_serializing_if = "Option::is_none")]
342 pub vertex_location: Option<String>,
343
344 #[serde(
350 skip_serializing_if = "Option::is_none",
351 alias = "python_package_manager"
352 )]
353 pub package_manager: Option<String>,
354
355 #[serde(skip_serializing_if = "Option::is_none")]
357 pub architect_model: Option<String>,
358
359 #[serde(skip_serializing_if = "Option::is_none")]
361 pub actuator_model: Option<String>,
362
363 #[serde(skip_serializing_if = "Option::is_none")]
365 pub verifier_model: Option<String>,
366
367 #[serde(skip_serializing_if = "Option::is_none")]
369 pub speculator_model: Option<String>,
370
371 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
375 pub providers: BTreeMap<String, ProviderEntry>,
376
377 #[serde(skip_serializing_if = "Option::is_none")]
381 pub models: Option<ModelsConfig>,
382
383 #[serde(skip_serializing_if = "Vec::is_empty")]
385 pub external_tools: Vec<ExternalToolConfig>,
386
387 #[serde(default, skip_serializing)]
392 pub ensemble: Option<toml::Value>,
393
394 #[serde(skip_serializing_if = "Option::is_none")]
396 pub verification: Option<VerificationConfig>,
397
398 #[serde(skip_serializing_if = "Option::is_none")]
400 pub exploration: Option<ExplorationConfig>,
401
402 #[serde(skip_serializing_if = "Option::is_none")]
405 pub prompts: Option<PromptsConfig>,
406
407 #[serde(skip_serializing_if = "Option::is_none")]
409 pub context: Option<ContextConfig>,
410}
411
412#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
415pub struct ContextConfig {
416 #[serde(skip_serializing_if = "Option::is_none")]
417 pub working_set_turns: Option<u32>,
418 #[serde(skip_serializing_if = "Option::is_none")]
419 pub synopsis_frame_tokens: Option<u64>,
420 #[serde(skip_serializing_if = "Option::is_none")]
421 pub output_reserve_tokens: Option<u64>,
422 #[serde(skip_serializing_if = "Option::is_none")]
423 pub guard_reserve_tokens: Option<u64>,
424}
425
426impl ContextConfig {
427 pub fn validate(&self) -> Result<()> {
429 for (name, value) in [
430 ("working_set_turns", self.working_set_turns.map(u64::from)),
431 ("synopsis_frame_tokens", self.synopsis_frame_tokens),
432 ("output_reserve_tokens", self.output_reserve_tokens),
433 ("guard_reserve_tokens", self.guard_reserve_tokens),
434 ] {
435 if value == Some(0) {
436 anyhow::bail!("[context] {name} must be positive");
437 }
438 }
439 Ok(())
440 }
441}
442
443#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
445pub struct VerificationConfig {
446 #[serde(skip_serializing_if = "Option::is_none")]
450 pub allow_unisolated: Option<bool>,
451 #[serde(skip_serializing_if = "Option::is_none")]
456 pub test_policy: Option<TestPolicy>,
457 #[serde(skip_serializing_if = "Option::is_none")]
459 pub external_oracle: Option<ExternalOracleConfig>,
460 #[serde(skip_serializing_if = "Option::is_none")]
463 pub require_format: Option<bool>,
464 #[serde(skip_serializing_if = "Option::is_none")]
466 pub stage_timeout_secs: Option<u64>,
467 #[serde(skip_serializing_if = "Option::is_none")]
469 pub syntax_timeout_secs: Option<u64>,
470 #[serde(skip_serializing_if = "Option::is_none")]
471 pub build_timeout_secs: Option<u64>,
472 #[serde(skip_serializing_if = "Option::is_none")]
473 pub test_timeout_secs: Option<u64>,
474 #[serde(skip_serializing_if = "Option::is_none")]
475 pub lint_timeout_secs: Option<u64>,
476 #[serde(skip_serializing_if = "Option::is_none")]
477 pub format_timeout_secs: Option<u64>,
478}
479
480impl VerificationConfig {
481 pub fn validate(&self) -> Result<()> {
482 for (name, value) in [
483 ("stage_timeout_secs", self.stage_timeout_secs),
484 ("syntax_timeout_secs", self.syntax_timeout_secs),
485 ("build_timeout_secs", self.build_timeout_secs),
486 ("test_timeout_secs", self.test_timeout_secs),
487 ("lint_timeout_secs", self.lint_timeout_secs),
488 ("format_timeout_secs", self.format_timeout_secs),
489 ] {
490 if value == Some(0) {
491 anyhow::bail!("[verification] {name} must be positive");
492 }
493 }
494 match (self.test_policy.unwrap_or_default(), &self.external_oracle) {
495 (TestPolicy::ExternalOracle, Some(oracle)) => oracle.validate()?,
496 (TestPolicy::ExternalOracle, None) => anyhow::bail!(
497 "[verification] test_policy = \"external-oracle\" requires \
498 [verification.external_oracle]"
499 ),
500 (_, Some(_)) => anyhow::bail!(
501 "[verification.external_oracle] requires \
502 test_policy = \"external-oracle\""
503 ),
504 (_, None) => {}
505 }
506 Ok(())
507 }
508}
509
510#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
512#[serde(rename_all = "kebab-case")]
513pub enum TestPolicy {
514 #[default]
517 Evolving,
518 BackwardCompatible,
522 ExternalOracle,
525}
526
527impl TestPolicy {
528 pub const fn as_str(self) -> &'static str {
529 match self {
530 Self::Evolving => "evolving",
531 Self::BackwardCompatible => "backward-compatible",
532 Self::ExternalOracle => "external-oracle",
533 }
534 }
535}
536
537#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
539pub struct ExternalOracleConfig {
540 pub path: PathBuf,
543 pub command: String,
545}
546
547impl ExternalOracleConfig {
548 fn validate(&self) -> Result<()> {
549 anyhow::ensure!(
550 !self.path.as_os_str().is_empty(),
551 "[verification.external_oracle] path must not be empty"
552 );
553 anyhow::ensure!(
554 !self.command.trim().is_empty(),
555 "[verification.external_oracle] command must not be empty"
556 );
557 Ok(())
558 }
559}
560
561#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
564pub struct ExplorationConfig {
565 #[serde(skip_serializing_if = "Option::is_none")]
567 pub initial_branches: Option<u8>,
568 #[serde(skip_serializing_if = "Option::is_none")]
570 pub max_branches: Option<u8>,
571 #[serde(skip_serializing_if = "Option::is_none")]
574 pub distinct_family: Option<bool>,
575 #[serde(skip_serializing_if = "Option::is_none")]
577 pub max_workspace_files: Option<u64>,
578 #[serde(skip_serializing_if = "Option::is_none")]
580 pub max_workspace_bytes: Option<u64>,
581}
582
583impl ExplorationConfig {
584 pub fn validate(&self) -> Result<()> {
587 let initial = self.initial_branches.unwrap_or(1);
588 let max = self.max_branches.unwrap_or(3);
589 anyhow::ensure!(
590 (1..=3).contains(&max),
591 "[exploration] max_branches must be between 1 and 3 (got {max})"
592 );
593 anyhow::ensure!(
594 initial >= 1 && initial <= max,
595 "[exploration] initial_branches must be between 1 and max_branches"
596 );
597 Ok(())
598 }
599}
600
601#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
605pub struct PromptsConfig {
606 #[serde(default, skip_serializing_if = "Vec::is_empty")]
608 pub bundles: Vec<String>,
609 #[serde(skip_serializing_if = "Option::is_none")]
611 pub activation_min_tasks: Option<u32>,
612 #[serde(skip_serializing_if = "Option::is_none")]
614 pub noninferiority_margin: Option<f64>,
615}
616
617impl PromptsConfig {
618 pub fn activation_bounds(&self) -> Result<perspt_sdk::prompt::ActivationBounds> {
620 let bounds = perspt_sdk::prompt::ActivationBounds {
621 min_tasks: self.activation_min_tasks.unwrap_or(30),
622 noninferiority_margin: self.noninferiority_margin.unwrap_or(0.05),
623 };
624 bounds
625 .validate()
626 .map_err(|e| anyhow::anyhow!("[prompts]: {e}"))?;
627 Ok(bounds)
628 }
629}
630
631impl Config {
632 pub fn from_toml_str(content: &str) -> Result<Self> {
634 let config: Self = toml::from_str(content).context("Failed to parse TOML configuration")?;
635 config.validate()?;
636 Ok(config)
637 }
638
639 pub fn load_from_path(path: &Path) -> Result<Self> {
642 if !path.exists() {
643 return Ok(Self::default());
644 }
645 let content = std::fs::read_to_string(path)
646 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
647 Self::from_toml_str(&content)
648 }
649
650 pub fn to_toml_string(&self) -> Result<String> {
652 toml::to_string_pretty(self).context("Failed to serialize configuration to TOML")
653 }
654
655 pub fn masked(&self) -> Self {
657 let mut clone = self.clone();
658 if clone.api_key.is_some() {
659 clone.api_key = Some(MASKED_API_KEY.to_string());
660 }
661 for entry in clone.providers.values_mut() {
662 if entry.api_key.is_some() {
663 entry.api_key = Some(MASKED_API_KEY.to_string());
664 }
665 }
666 clone
667 }
668
669 pub fn validate(&self) -> Result<()> {
670 anyhow::ensure!(
671 self.ensemble.is_none(),
672 "the [ensemble] section was removed by PSP-10: the ensemble is replaced \
673 by the bounded search forest. Configure [exploration] instead \
674 (initial_branches, max_branches, distinct_family) and delete [ensemble]."
675 );
676 if let Some(exploration) = &self.exploration {
677 exploration.validate()?;
678 }
679 if let Some(prompts) = &self.prompts {
680 prompts.activation_bounds()?;
681 }
682 if self.models.as_ref().and_then(|m| m.turn_timeout_secs) == Some(0) {
683 anyhow::bail!("[models] turn_timeout_secs must be positive");
684 }
685 if let Some(context) = &self.context {
686 context.validate()?;
687 }
688 if let Some(verification) = &self.verification {
689 verification.validate()?;
690 }
691 let mut server_ids = std::collections::BTreeSet::new();
692 for server in &self.external_tools {
693 server.validate()?;
694 anyhow::ensure!(
695 server_ids.insert(server.id.as_str()),
696 "duplicate external tool server id {:?}",
697 server.id
698 );
699 }
700 Ok(())
701 }
702
703 pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
707 let value = value.to_string();
708 match key {
709 "provider" | "provider_type" | "default_provider" => self.provider = Some(value),
710 "model" | "default_model" => self.model = Some(value),
711 "api_key" => self.api_key = Some(value),
712 "base_url" => self.base_url = Some(value),
713 "vertex_project_id" => self.vertex_project_id = Some(value),
714 "vertex_location" => self.vertex_location = Some(value),
715 "architect_model" => self.architect_model = Some(value),
716 "actuator_model" => self.actuator_model = Some(value),
717 "verifier_model" => self.verifier_model = Some(value),
718 "speculator_model" => self.speculator_model = Some(value),
719 "package_manager" | "python_package_manager" => self.package_manager = Some(value),
720 other => anyhow::bail!(
721 "Unknown configuration key: {other}. Valid keys: provider, model, api_key, \
722 base_url, vertex_project_id, vertex_location, architect_model, actuator_model, \
723 verifier_model, speculator_model, package_manager"
724 ),
725 }
726 Ok(())
727 }
728}
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733
734 #[test]
735 fn empty_string_parses_to_defaults() {
736 let cfg = Config::from_toml_str("").unwrap();
737 assert!(cfg.provider.is_none());
738 assert!(cfg.model.is_none());
739 assert!(cfg.api_key.is_none());
740 }
741
742 #[test]
743 fn test_evidence_policies_parse_and_evolving_is_the_default() {
744 assert_eq!(TestPolicy::default(), TestPolicy::Evolving);
745 let cfg = Config::from_toml_str(
746 r#"
747 [verification]
748 test_policy = "external-oracle"
749
750 [verification.external_oracle]
751 path = "/trusted/acceptance"
752 command = "cargo test --test acceptance"
753 "#,
754 )
755 .unwrap();
756 let verification = cfg.verification.unwrap();
757 assert_eq!(verification.test_policy, Some(TestPolicy::ExternalOracle));
758 assert_eq!(
759 verification.external_oracle.unwrap().command,
760 "cargo test --test acceptance"
761 );
762 }
763
764 #[test]
765 fn external_oracle_configuration_is_never_silently_ignored() {
766 let missing = Config::from_toml_str(
767 r#"
768 [verification]
769 test_policy = "external-oracle"
770 "#,
771 )
772 .unwrap_err();
773 assert!(missing.to_string().contains("external_oracle"));
774
775 let inactive = Config::from_toml_str(
776 r#"
777 [verification]
778 test_policy = "evolving"
779
780 [verification.external_oracle]
781 path = "/trusted/acceptance"
782 command = "cargo test"
783 "#,
784 )
785 .unwrap_err();
786 assert!(inactive.to_string().contains("external-oracle"));
787 }
788
789 #[test]
790 fn package_manager_set_value_and_alias() {
791 let mut cfg = Config::default();
792 cfg.set_value("package_manager", "poetry").unwrap();
793 assert_eq!(cfg.package_manager.as_deref(), Some("poetry"));
794 let mut cfg2 = Config::default();
796 cfg2.set_value("python_package_manager", "pdm").unwrap();
797 assert_eq!(cfg2.package_manager.as_deref(), Some("pdm"));
798 }
799
800 #[test]
801 fn aliases_are_accepted() {
802 let cfg = Config::from_toml_str(
803 r#"
804 provider_type = "openai"
805 default_model = "phi-4-npu-ov"
806 "#,
807 )
808 .unwrap();
809 assert_eq!(cfg.provider.as_deref(), Some("openai"));
810 assert_eq!(cfg.model.as_deref(), Some("phi-4-npu-ov"));
811 }
812
813 #[test]
814 fn missing_file_returns_default() {
815 let path = Path::new("/nonexistent/perspt/config.toml");
816 let cfg = Config::load_from_path(path).unwrap();
817 assert!(cfg.provider.is_none());
818 }
819
820 #[test]
821 fn masked_hides_api_key() {
822 let cfg = Config {
823 api_key: Some("super-secret".to_string()),
824 ..Default::default()
825 };
826 assert_eq!(cfg.masked().api_key.as_deref(), Some("***"));
827 }
828
829 #[test]
830 fn masked_leaves_absent_key_absent() {
831 let cfg = Config::default();
832 assert!(cfg.masked().api_key.is_none());
833 }
834
835 #[test]
836 fn set_value_updates_known_keys() {
837 let mut cfg = Config::default();
838 cfg.set_value("default_model", "phi-4-npu-ov").unwrap();
839 assert_eq!(cfg.model.as_deref(), Some("phi-4-npu-ov"));
840 cfg.set_value("provider", "openai").unwrap();
841 assert_eq!(cfg.provider.as_deref(), Some("openai"));
842 cfg.set_value("vertex_project_id", "test-project").unwrap();
843 cfg.set_value("vertex_location", "test-location").unwrap();
844 assert_eq!(cfg.vertex_project_id.as_deref(), Some("test-project"));
845 assert_eq!(cfg.vertex_location.as_deref(), Some("test-location"));
846 }
847
848 #[test]
849 fn providers_table_parses_with_defaulted_adapter() {
850 let cfg = Config::from_toml_str(
851 r#"
852 [providers.anthropic]
853 api_key_env = "ANTHROPIC_API_KEY"
854
855 [providers.local]
856 adapter = "ollama"
857 base_url = "http://localhost:11434"
858
859 [models]
860 architect = "anthropic::claude-opus-5"
861 speculator = "local::qwen2.5-coder"
862 "#,
863 )
864 .unwrap();
865 assert_eq!(cfg.providers.len(), 2);
866 let anthropic = &cfg.providers["anthropic"];
867 assert_eq!(anthropic.adapter_or("anthropic"), "anthropic");
868 assert_eq!(anthropic.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
869 let local = &cfg.providers["local"];
870 assert_eq!(local.adapter_or("local"), "ollama");
871 let models = cfg.models.unwrap();
872 assert_eq!(
873 models.architect.as_deref(),
874 Some("anthropic::claude-opus-5")
875 );
876 assert_eq!(models.adjudicator, None);
877 }
878
879 #[test]
880 fn masked_hides_provider_table_keys_too() {
881 let cfg = Config::from_toml_str(
882 r#"
883 [providers.openai]
884 api_key = "sk-super-secret"
885 "#,
886 )
887 .unwrap();
888 assert_eq!(
889 cfg.masked().providers["openai"].api_key.as_deref(),
890 Some("***")
891 );
892 }
893
894 #[test]
895 fn flat_config_still_parses_without_tables() {
896 let cfg = Config::from_toml_str("provider = \"openai\"\n").unwrap();
897 assert!(cfg.providers.is_empty());
898 assert!(cfg.models.is_none());
899 }
900
901 #[test]
902 fn set_value_rejects_unknown_key() {
903 let mut cfg = Config::default();
904 assert!(cfg.set_value("nope", "x").is_err());
905 }
906
907 #[test]
908 fn round_trip_set_does_not_duplicate() {
909 let mut cfg = Config::default();
910 cfg.set_value("default_model", "a").unwrap();
911 cfg.set_value("default_model", "b").unwrap();
912 let serialized = cfg.to_toml_string().unwrap();
913 assert_eq!(serialized.matches("model").count(), 1);
915 let reparsed = Config::from_toml_str(&serialized).unwrap();
916 assert_eq!(reparsed.model.as_deref(), Some("b"));
917 }
918
919 #[test]
920 fn external_tools_default_to_agent_only() {
921 let config = Config::from_toml_str(
922 r#"
923 [[external_tools]]
924 id = "local-search"
925 transport = "stdio"
926 command = ["search-server", "--stdio"]
927 "#,
928 )
929 .unwrap();
930 let server = &config.external_tools[0];
931 assert!(server.supports(ExternalToolMode::Agent));
932 assert!(!server.supports(ExternalToolMode::Chat));
933 }
934
935 #[test]
936 fn external_tool_modes_and_local_policy_parse() {
937 let config = Config::from_toml_str(
938 r#"
939 [[external_tools]]
940 id = "records"
941 transport = "streamable_http"
942 url = "https://tools.example.test/mcp"
943 modes = ["agent", "chat"]
944 headers_from_env = { Authorization = "RECORDS_AUTH" }
945
946 [external_tools.tools.lookup]
947 effect = "data_read"
948 risk = "low"
949 "#,
950 )
951 .unwrap();
952 let server = &config.external_tools[0];
953 assert!(server.supports(ExternalToolMode::Agent));
954 assert!(server.supports(ExternalToolMode::Chat));
955 assert_eq!(server.tools["lookup"].effect, Some(EffectKind::DataRead));
956 }
957
958 #[test]
959 fn mcp_options_validate() {
960 let config = Config::from_toml_str(
961 r#"
962 [[external_tools]]
963 id = "complete"
964 transport = "stdio"
965 command = ["server"]
966 sampling = true
967 elicitation = true
968 subscriptions = true
969 resource_subscriptions = ["file:///workspace/guide.md"]
970 tasks = true
971 max_sampling_tokens = 2048
972 max_task_wait_ms = 45000
973 roots = [{ uri = "file:///workspace", name = "Project" }]
974 "#,
975 )
976 .unwrap();
977 let server = &config.external_tools[0];
978 assert!(server.sampling && server.elicitation && server.subscriptions && server.tasks);
979 assert_eq!(server.roots[0].name.as_deref(), Some("Project"));
980 assert_eq!(server.max_task_wait_ms, 45_000);
981 }
982
983 #[test]
984 fn invalid_mcp_config_is_rejected() {
985 assert!(Config::from_toml_str(
986 r#"
987 [[external_tools]]
988 id = "legacy"
989 transport = "http"
990 url = "https://example.test/mcp"
991 "#,
992 )
993 .is_err());
994 assert!(Config::from_toml_str(
995 r#"
996 [[external_tools]]
997 id = "bad-root"
998 transport = "stdio"
999 command = ["server"]
1000 roots = [{ uri = "https://example.test/not-a-root" }]
1001 "#,
1002 )
1003 .is_err());
1004 assert!(Config::from_toml_str(
1005 r#"
1006 [[external_tools]]
1007 id = "bad-task-bound"
1008 transport = "stdio"
1009 command = ["server"]
1010 max_task_wait_ms = 0
1011 "#,
1012 )
1013 .is_err());
1014 }
1015
1016 #[test]
1017 fn duplicate_external_server_ids_are_rejected() {
1018 let invalid = r#"
1019 [[external_tools]]
1020 id = "same"
1021 transport = "stdio"
1022 command = ["one"]
1023
1024 [[external_tools]]
1025 id = "same"
1026 transport = "stdio"
1027 command = ["two"]
1028 "#;
1029 assert!(Config::from_toml_str(invalid).is_err());
1030 }
1031}
1032
1033#[cfg(test)]
1034mod psp10_config_tests {
1035 use super::*;
1036
1037 #[test]
1038 fn a_present_ensemble_block_fails_with_a_pointed_migration_error() {
1039 let error = Config::from_toml_str("[ensemble]\nwidth = 2\n").unwrap_err();
1040 let message = format!("{error:#}");
1041 assert!(message.contains("[exploration]"), "{message}");
1042 assert!(message.contains("PSP-10"), "{message}");
1043 }
1044
1045 #[test]
1046 fn exploration_bounds_are_enforced_at_startup() {
1047 assert!(Config::from_toml_str("[exploration]\nmax_branches = 4\n").is_err());
1048 assert!(Config::from_toml_str("[exploration]\ninitial_branches = 0\n").is_err());
1049 let config =
1050 Config::from_toml_str("[exploration]\ninitial_branches = 1\nmax_branches = 3\n")
1051 .unwrap();
1052 config.exploration.unwrap().validate().unwrap();
1053 }
1054
1055 #[test]
1056 fn prompt_activation_bounds_are_floor_and_range_checked() {
1057 assert!(Config::from_toml_str("[prompts]\nactivation_min_tasks = 29\n").is_err());
1058 assert!(Config::from_toml_str("[prompts]\nnoninferiority_margin = 0.06\n").is_err());
1059 let config = Config::from_toml_str(
1060 "[prompts]\nactivation_min_tasks = 40\nnoninferiority_margin = 0.01\n",
1061 )
1062 .unwrap();
1063 let bounds = config.prompts.unwrap().activation_bounds().unwrap();
1064 assert_eq!(bounds.min_tasks, 40);
1065 }
1066}