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::path::Path;
10
11/// Placeholder shown instead of a real API key in `config --show`.
12const MASKED_API_KEY: &str = "***";
13
14/// Main configuration struct.
15///
16/// All fields are optional. Documented aliases are accepted on load so that
17/// older field names keep working (`provider_type`, `default_provider`,
18/// `default_model`).
19#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20#[serde(default)]
21pub struct Config {
22    /// Provider id, e.g. `openai`, `anthropic`, `gemini`, `ollama`.
23    #[serde(
24        alias = "provider_type",
25        alias = "default_provider",
26        skip_serializing_if = "Option::is_none"
27    )]
28    pub provider: Option<String>,
29
30    /// Default chat/simple-chat model.
31    #[serde(alias = "default_model", skip_serializing_if = "Option::is_none")]
32    pub model: Option<String>,
33
34    /// API key for the configured provider. Optional; may also come from env.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub api_key: Option<String>,
37
38    /// Optional base URL override for OpenAI-compatible / local endpoints.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub base_url: Option<String>,
41
42    /// Agent Architect-tier model override.
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub architect_model: Option<String>,
45
46    /// Agent Actuator-tier model override.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub actuator_model: Option<String>,
49
50    /// Agent Verifier-tier model override.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub verifier_model: Option<String>,
53
54    /// Agent Speculator-tier model override.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub speculator_model: Option<String>,
57}
58
59impl Config {
60    /// Parse a `Config` from a TOML string. A partial document is valid.
61    pub fn from_toml_str(content: &str) -> Result<Self> {
62        toml::from_str(content).context("Failed to parse TOML configuration")
63    }
64
65    /// Load a `Config` from a file path. Returns `Config::default()` when the
66    /// file does not exist, so callers can always work with effective values.
67    pub fn load_from_path(path: &Path) -> Result<Self> {
68        if !path.exists() {
69            return Ok(Self::default());
70        }
71        let content = std::fs::read_to_string(path)
72            .with_context(|| format!("Failed to read config file: {}", path.display()))?;
73        Self::from_toml_str(&content)
74    }
75
76    /// Serialize this config to a TOML string.
77    pub fn to_toml_string(&self) -> Result<String> {
78        toml::to_string_pretty(self).context("Failed to serialize configuration to TOML")
79    }
80
81    /// Return a clone with the API key masked, for display purposes.
82    pub fn masked(&self) -> Self {
83        let mut clone = self.clone();
84        if clone.api_key.is_some() {
85            clone.api_key = Some(MASKED_API_KEY.to_string());
86        }
87        clone
88    }
89
90    /// Set a single key to a string value, used by `config --set`.
91    ///
92    /// Returns an error for unknown keys so typos surface immediately.
93    pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
94        let value = value.to_string();
95        match key {
96            "provider" | "provider_type" | "default_provider" => self.provider = Some(value),
97            "model" | "default_model" => self.model = Some(value),
98            "api_key" => self.api_key = Some(value),
99            "base_url" => self.base_url = Some(value),
100            "architect_model" => self.architect_model = Some(value),
101            "actuator_model" => self.actuator_model = Some(value),
102            "verifier_model" => self.verifier_model = Some(value),
103            "speculator_model" => self.speculator_model = Some(value),
104            other => anyhow::bail!(
105                "Unknown configuration key: {other}. Valid keys: provider, model, api_key, \
106                 base_url, architect_model, actuator_model, verifier_model, speculator_model"
107            ),
108        }
109        Ok(())
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn empty_string_parses_to_defaults() {
119        let cfg = Config::from_toml_str("").unwrap();
120        assert!(cfg.provider.is_none());
121        assert!(cfg.model.is_none());
122        assert!(cfg.api_key.is_none());
123    }
124
125    #[test]
126    fn aliases_are_accepted() {
127        let cfg = Config::from_toml_str(
128            r#"
129            provider_type = "openai"
130            default_model = "phi-4-npu-ov"
131            "#,
132        )
133        .unwrap();
134        assert_eq!(cfg.provider.as_deref(), Some("openai"));
135        assert_eq!(cfg.model.as_deref(), Some("phi-4-npu-ov"));
136    }
137
138    #[test]
139    fn missing_file_returns_default() {
140        let path = Path::new("/nonexistent/perspt/config.toml");
141        let cfg = Config::load_from_path(path).unwrap();
142        assert!(cfg.provider.is_none());
143    }
144
145    #[test]
146    fn masked_hides_api_key() {
147        let cfg = Config {
148            api_key: Some("super-secret".to_string()),
149            ..Default::default()
150        };
151        assert_eq!(cfg.masked().api_key.as_deref(), Some("***"));
152    }
153
154    #[test]
155    fn masked_leaves_absent_key_absent() {
156        let cfg = Config::default();
157        assert!(cfg.masked().api_key.is_none());
158    }
159
160    #[test]
161    fn set_value_updates_known_keys() {
162        let mut cfg = Config::default();
163        cfg.set_value("default_model", "phi-4-npu-ov").unwrap();
164        assert_eq!(cfg.model.as_deref(), Some("phi-4-npu-ov"));
165        cfg.set_value("provider", "openai").unwrap();
166        assert_eq!(cfg.provider.as_deref(), Some("openai"));
167    }
168
169    #[test]
170    fn set_value_rejects_unknown_key() {
171        let mut cfg = Config::default();
172        assert!(cfg.set_value("nope", "x").is_err());
173    }
174
175    #[test]
176    fn round_trip_set_does_not_duplicate() {
177        let mut cfg = Config::default();
178        cfg.set_value("default_model", "a").unwrap();
179        cfg.set_value("default_model", "b").unwrap();
180        let serialized = cfg.to_toml_string().unwrap();
181        // Exactly one model line after two sets.
182        assert_eq!(serialized.matches("model").count(), 1);
183        let reparsed = Config::from_toml_str(&serialized).unwrap();
184        assert_eq!(reparsed.model.as_deref(), Some("b"));
185    }
186}