Skip to main content

perspt_core/
config.rs

1//! Configuration types for Perspt.
2//!
3//! The on-disk configuration is TOML. Every field is optional so that a missing
4//! or partial config file never errors; effective values are computed by merging
5//! the file with environment-based detection and built-in defaults.
6
7use 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
14/// Placeholder shown instead of a real API key in `config --show`.
15const MASKED_API_KEY: &str = "***";
16
17/// One entry of the `[providers.<id>]` table (PSP-9 system 1).
18///
19/// A provider record describes *resolution policy*, not embedded secrets:
20/// `api_key_env` names an environment variable; a literal `api_key` is
21/// accepted for local development but masked on display. Vertex entries may
22/// omit a static key entirely and use the per-request ADC token resolver.
23#[derive(Debug, Clone, Default, Serialize, Deserialize)]
24#[serde(default)]
25pub struct ProviderEntry {
26    /// genai adapter kind. Defaults to the table key, so
27    /// `[providers.anthropic]` needs no explicit adapter while
28    /// `[providers.local]` can say `adapter = "ollama"`.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub adapter: Option<String>,
31    /// Environment variable holding the API key.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub api_key_env: Option<String>,
34    /// Literal API key. Prefer `api_key_env`.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub api_key: Option<String>,
37    /// Base URL override for OpenAI-compatible / local endpoints.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub base_url: Option<String>,
40    /// Google Cloud project id (Vertex only; ADC discovery when absent).
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub project_id: Option<String>,
43    /// Vertex location; defaults to `global` via the existing resolver.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub location: Option<String>,
46}
47
48impl ProviderEntry {
49    /// The adapter this entry binds, defaulting to the table key.
50    pub fn adapter_or<'a>(&'a self, key: &'a str) -> &'a str {
51        self.adapter.as_deref().unwrap_or(key)
52    }
53}
54
55/// The `[models]` table: fully qualified `provider::model` route values per
56/// tier (PSP-9 system 1). Values stay fully qualified so identity,
57/// calibration, and replay never depend on an ambient default provider.
58#[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    /// Optional adjudication route (PSP-9 system 8).
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub adjudicator: Option<String>,
72    /// Hard wall-clock deadline for one model turn, in seconds (default
73    /// 120). A turn that exceeds it is an ordinary transport failure: it
74    /// consumes sticky failover instead of hanging the session.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub turn_timeout_secs: Option<u64>,
77}
78
79/// Product surface allowed to create a lifecycle for an external server.
80#[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/// MCP transports supported by Perspt 0.6.6.
92#[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/// One filesystem root that an MCP server may learn through ``roots/list``.
128/// Roots are never inferred: each URI is an explicit disclosure boundary.
129#[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/// Locally trusted governance declaration for one discovered remote tool.
138#[derive(Debug, Clone, Default, Serialize, Deserialize)]
139#[serde(default)]
140pub struct ExternalToolPolicy {
141    /// Omission fails closed to `RunShell`.
142    pub effect: Option<EffectKind>,
143    pub risk: Option<RiskClass>,
144    /// Omission fails closed to an opaque workspace footprint.
145    pub footprint: Option<FootprintSpec>,
146    pub proposal_bindings: Vec<ProposalBinding>,
147}
148
149/// One `[[external_tools]]` server. Secret values are never stored here:
150/// environment maps contain destination/header names and source variable names.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152#[serde(default)]
153pub struct ExternalToolConfig {
154    pub id: String,
155    pub transport: ExternalToolTransport,
156    /// Direct argv. Element zero is the program; no shell parsing occurs.
157    pub command: Vec<String>,
158    pub url: Option<String>,
159    /// Child variable -> source environment variable, for stdio servers.
160    pub env_from_env: BTreeMap<String, String>,
161    /// HTTP header -> source environment variable.
162    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    /// Explicit roots returned to server-initiated ``roots/list`` requests.
172    /// An empty list means that the roots capability is not advertised.
173    pub roots: Vec<McpRootConfig>,
174    /// Allow server-initiated model sampling. This is opt-in because it spends
175    /// the configured provider's model budget.
176    pub sampling: bool,
177    /// Allow server-initiated elicitation. A product surface must also install
178    /// an interactive handler; otherwise connection fails closed.
179    pub elicitation: bool,
180    /// Open the MCP 2026-07-28 ``subscriptions/listen`` stream for advertised
181    /// list-change notifications and the resource URIs below.
182    #[serde(default = "default_subscriptions")]
183    pub subscriptions: bool,
184    pub resource_subscriptions: Vec<String>,
185    /// Advertise and drive the official tasks extension for long-running calls.
186    #[serde(default = "default_tasks")]
187    pub tasks: bool,
188    /// Overall deadline for one asynchronous MCP task, across every poll and
189    /// input-required round. Individual requests still use ``timeout_ms``.
190    #[serde(default = "default_task_wait_ms")]
191    pub max_task_wait_ms: u64,
192    /// Local upper bound on a server sampling request.
193    #[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/// Main configuration struct.
308///
309/// All fields are optional. Documented aliases are accepted on load so that
310/// older field names keep working (`provider_type`, `default_provider`,
311/// `default_model`).
312#[derive(Debug, Clone, Default, Serialize, Deserialize)]
313#[serde(default)]
314pub struct Config {
315    /// Provider id, e.g. `openai`, `anthropic`, `gemini`, `vertex`, `ollama`.
316    #[serde(
317        alias = "provider_type",
318        alias = "default_provider",
319        skip_serializing_if = "Option::is_none"
320    )]
321    pub provider: Option<String>,
322
323    /// Default chat/simple-chat model.
324    #[serde(alias = "default_model", skip_serializing_if = "Option::is_none")]
325    pub model: Option<String>,
326
327    /// API key for the configured provider. Optional; may also come from env.
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub api_key: Option<String>,
330
331    /// Optional base URL override for OpenAI-compatible / local endpoints.
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub base_url: Option<String>,
334
335    /// Google Cloud project id for Vertex AI. Optional; may also come from
336    /// `VERTEX_PROJECT_ID` or Google Cloud project environment variables.
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub vertex_project_id: Option<String>,
339
340    /// Vertex AI location. Optional; may also come from `VERTEX_LOCATION`.
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub vertex_location: Option<String>,
343
344    /// Preferred package manager for greenfield project init. Optional and
345    /// fully plugin-driven: the active language plugin maps it to its own init
346    /// command and default (e.g. Python → `uv`, JS → `npm`). Unknown values fall
347    /// back to each plugin's default. Examples: `uv`, `poetry`, `pdm`, `pipenv`
348    /// (Python); `pnpm`, `yarn` (JS).
349    #[serde(
350        skip_serializing_if = "Option::is_none",
351        alias = "python_package_manager"
352    )]
353    pub package_manager: Option<String>,
354
355    /// Agent Architect-tier model override.
356    #[serde(skip_serializing_if = "Option::is_none")]
357    pub architect_model: Option<String>,
358
359    /// Agent Actuator-tier model override.
360    #[serde(skip_serializing_if = "Option::is_none")]
361    pub actuator_model: Option<String>,
362
363    /// Agent Verifier-tier model override.
364    #[serde(skip_serializing_if = "Option::is_none")]
365    pub verifier_model: Option<String>,
366
367    /// Agent Speculator-tier model override.
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub speculator_model: Option<String>,
370
371    /// The `[providers.<id>]` table: several credentials coexisting in one
372    /// process (PSP-9 system 1). Empty for single-provider configurations,
373    /// which continue to use the flat fields above.
374    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
375    pub providers: BTreeMap<String, ProviderEntry>,
376
377    /// The `[models]` table: per-tier fully qualified `provider::model`
378    /// routes. When present it takes precedence over the flat
379    /// `*_model` fields.
380    #[serde(skip_serializing_if = "Option::is_none")]
381    pub models: Option<ModelsConfig>,
382
383    /// Shared MCP server configuration. Omitted modes default to agent-only.
384    #[serde(skip_serializing_if = "Vec::is_empty")]
385    pub external_tools: Vec<ExternalToolConfig>,
386
387    /// Detector for the removed PSP-9 `[ensemble]` block. Deserialization
388    /// only: a present block fails validation with a pointed migration
389    /// error naming `[exploration]` (PSP-10 cutover), never a silent
390    /// ignore.
391    #[serde(default, skip_serializing)]
392    pub ensemble: Option<toml::Value>,
393
394    /// Verification acceptance-stage options (`[verification]`).
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub verification: Option<VerificationConfig>,
397
398    /// Bounded search configuration (`[exploration]`; PSP-10 system 20).
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub exploration: Option<ExplorationConfig>,
401
402    /// Prompt bundles and activation bounds (`[prompts]`; PSP-10
403    /// system 25).
404    #[serde(skip_serializing_if = "Option::is_none")]
405    pub prompts: Option<PromptsConfig>,
406
407    /// Resident-context reserves (`[context]`; PSP-10 Definition 6).
408    #[serde(skip_serializing_if = "Option::is_none")]
409    pub context: Option<ContextConfig>,
410}
411
412/// The `[context]` block (PSP-10 Definition 6): reserves and working-set
413/// bounds for the paged resident context.
414#[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    /// Startup validation: every configured reserve must be positive.
428    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/// The `[verification]` acceptance-stage options.
444#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
445pub struct VerificationConfig {
446    /// Permit verifier, inspection, and LSP processes to run without an OS
447    /// sandbox when the current platform has no registered backend. This is
448    /// an explicit reduced-isolation mode; false by default.
449    #[serde(skip_serializing_if = "Option::is_none")]
450    pub allow_unisolated: Option<bool>,
451    /// How project tests contribute to acceptance. `evolving` is the normal
452    /// development policy: the resulting code, tests, and configuration are
453    /// verified together. Stronger policies are explicit because historical
454    /// tests are not universally valid after an intentional contract change.
455    #[serde(skip_serializing_if = "Option::is_none")]
456    pub test_policy: Option<TestPolicy>,
457    /// Protected acceptance material used only by `external-oracle`.
458    #[serde(skip_serializing_if = "Option::is_none")]
459    pub external_oracle: Option<ExternalOracleConfig>,
460    /// Declare the plugin `format` verifier stage (e.g. `cargo fmt --check`)
461    /// as an acceptance sensor. Off by default.
462    #[serde(skip_serializing_if = "Option::is_none")]
463    pub require_format: Option<bool>,
464    /// Wall-clock limit for every governed verifier stage (default 180).
465    #[serde(skip_serializing_if = "Option::is_none")]
466    pub stage_timeout_secs: Option<u64>,
467    /// Per-stage overrides of `stage_timeout_secs`.
468    #[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/// Test-evidence policy at a coding gate.
511#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
512#[serde(rename_all = "kebab-case")]
513pub enum TestPolicy {
514    /// Verify the resulting implementation, tests, and project configuration.
515    /// Tests are ordinary mutable project artifacts.
516    #[default]
517    Evolving,
518    /// In addition to the resulting suite, run recognized pre-existing test
519    /// files against the candidate implementation. Intended only for tasks
520    /// that promise backward compatibility.
521    BackwardCompatible,
522    /// In addition to the resulting suite, overlay separately supplied,
523    /// protected acceptance material and run its configured command.
524    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/// Protected files and command for `TestPolicy::ExternalOracle`.
538#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
539pub struct ExternalOracleConfig {
540    /// Directory overlaid onto a private copy of the candidate only at the
541    /// acceptance boundary. It may contain tests, manifests, or harness code.
542    pub path: PathBuf,
543    /// Command executed from the root of that private verification copy.
544    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/// The `[exploration]` search block (PSP-10 system 20). Sequential eager
562/// branches only; the hard branch cap is 3 for this release.
563#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
564pub struct ExplorationConfig {
565    /// Branches opened before any expansion trigger (default 1).
566    #[serde(skip_serializing_if = "Option::is_none")]
567    pub initial_branches: Option<u8>,
568    /// Branch identities per forest, children included (hard cap 3).
569    #[serde(skip_serializing_if = "Option::is_none")]
570    pub max_branches: Option<u8>,
571    /// Prefer a distinct model family on expansion (a prior, never a
572    /// certificate).
573    #[serde(skip_serializing_if = "Option::is_none")]
574    pub distinct_family: Option<bool>,
575    /// Cumulative eager-copy file reservation cap.
576    #[serde(skip_serializing_if = "Option::is_none")]
577    pub max_workspace_files: Option<u64>,
578    /// Cumulative eager-copy byte reservation cap.
579    #[serde(skip_serializing_if = "Option::is_none")]
580    pub max_workspace_bytes: Option<u64>,
581}
582
583impl ExplorationConfig {
584    /// Validate the branch bounds (PSP-10: at most three branches, at
585    /// least one).
586    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/// The `[prompts]` block (PSP-10 system 25, Gate AE). The activation floor
602/// may only be raised and the margin narrowed; invalid values fail at
603/// startup.
604#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
605pub struct PromptsConfig {
606    /// External replacement bundle directories, pinned at session start.
607    #[serde(default, skip_serializing_if = "Vec::is_empty")]
608    pub bundles: Vec<String>,
609    /// Minimum paired activation tasks (floor 30; raise-only).
610    #[serde(skip_serializing_if = "Option::is_none")]
611    pub activation_min_tasks: Option<u32>,
612    /// Noninferiority margin epsilon in [0, 0.05].
613    #[serde(skip_serializing_if = "Option::is_none")]
614    pub noninferiority_margin: Option<f64>,
615}
616
617impl PromptsConfig {
618    /// The activation bounds after startup validation.
619    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    /// Parse a `Config` from a TOML string. A partial document is valid.
633    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    /// Load a `Config` from a file path. Returns `Config::default()` when the
640    /// file does not exist, so callers can always work with effective values.
641    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    /// Serialize this config to a TOML string.
651    pub fn to_toml_string(&self) -> Result<String> {
652        toml::to_string_pretty(self).context("Failed to serialize configuration to TOML")
653    }
654
655    /// Return a clone with the API key masked, for display purposes.
656    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    /// Set a single key to a string value, used by `config --set`.
704    ///
705    /// Returns an error for unknown keys so typos surface immediately.
706    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        // The python-specific key is accepted as an alias for clarity.
795        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        // Exactly one model line after two sets.
914        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}