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`, `vertex`, `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    /// Google Cloud project id for Vertex AI. Optional; may also come from
43    /// `VERTEX_PROJECT_ID` or Google Cloud project environment variables.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub vertex_project_id: Option<String>,
46
47    /// Vertex AI location. Optional; may also come from `VERTEX_LOCATION`.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub vertex_location: Option<String>,
50
51    /// Preferred package manager for greenfield project init. Optional and
52    /// fully plugin-driven: the active language plugin maps it to its own init
53    /// command and default (e.g. Python → `uv`, JS → `npm`). Unknown values fall
54    /// back to each plugin's default. Examples: `uv`, `poetry`, `pdm`, `pipenv`
55    /// (Python); `pnpm`, `yarn` (JS).
56    #[serde(
57        skip_serializing_if = "Option::is_none",
58        alias = "python_package_manager"
59    )]
60    pub package_manager: Option<String>,
61
62    /// Agent Architect-tier model override.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub architect_model: Option<String>,
65
66    /// Agent Actuator-tier model override.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub actuator_model: Option<String>,
69
70    /// Agent Verifier-tier model override.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub verifier_model: Option<String>,
73
74    /// Agent Speculator-tier model override.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub speculator_model: Option<String>,
77}
78
79impl Config {
80    /// Parse a `Config` from a TOML string. A partial document is valid.
81    pub fn from_toml_str(content: &str) -> Result<Self> {
82        toml::from_str(content).context("Failed to parse TOML configuration")
83    }
84
85    /// Load a `Config` from a file path. Returns `Config::default()` when the
86    /// file does not exist, so callers can always work with effective values.
87    pub fn load_from_path(path: &Path) -> Result<Self> {
88        if !path.exists() {
89            return Ok(Self::default());
90        }
91        let content = std::fs::read_to_string(path)
92            .with_context(|| format!("Failed to read config file: {}", path.display()))?;
93        Self::from_toml_str(&content)
94    }
95
96    /// Serialize this config to a TOML string.
97    pub fn to_toml_string(&self) -> Result<String> {
98        toml::to_string_pretty(self).context("Failed to serialize configuration to TOML")
99    }
100
101    /// Return a clone with the API key masked, for display purposes.
102    pub fn masked(&self) -> Self {
103        let mut clone = self.clone();
104        if clone.api_key.is_some() {
105            clone.api_key = Some(MASKED_API_KEY.to_string());
106        }
107        clone
108    }
109
110    /// Set a single key to a string value, used by `config --set`.
111    ///
112    /// Returns an error for unknown keys so typos surface immediately.
113    pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
114        let value = value.to_string();
115        match key {
116            "provider" | "provider_type" | "default_provider" => self.provider = Some(value),
117            "model" | "default_model" => self.model = Some(value),
118            "api_key" => self.api_key = Some(value),
119            "base_url" => self.base_url = Some(value),
120            "vertex_project_id" => self.vertex_project_id = Some(value),
121            "vertex_location" => self.vertex_location = Some(value),
122            "architect_model" => self.architect_model = Some(value),
123            "actuator_model" => self.actuator_model = Some(value),
124            "verifier_model" => self.verifier_model = Some(value),
125            "speculator_model" => self.speculator_model = Some(value),
126            "package_manager" | "python_package_manager" => self.package_manager = Some(value),
127            other => anyhow::bail!(
128                "Unknown configuration key: {other}. Valid keys: provider, model, api_key, \
129                 base_url, vertex_project_id, vertex_location, architect_model, actuator_model, \
130                 verifier_model, speculator_model, package_manager"
131            ),
132        }
133        Ok(())
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn empty_string_parses_to_defaults() {
143        let cfg = Config::from_toml_str("").unwrap();
144        assert!(cfg.provider.is_none());
145        assert!(cfg.model.is_none());
146        assert!(cfg.api_key.is_none());
147    }
148
149    #[test]
150    fn package_manager_set_value_and_alias() {
151        let mut cfg = Config::default();
152        cfg.set_value("package_manager", "poetry").unwrap();
153        assert_eq!(cfg.package_manager.as_deref(), Some("poetry"));
154        // The python-specific key is accepted as an alias for clarity.
155        let mut cfg2 = Config::default();
156        cfg2.set_value("python_package_manager", "pdm").unwrap();
157        assert_eq!(cfg2.package_manager.as_deref(), Some("pdm"));
158    }
159
160    #[test]
161    fn aliases_are_accepted() {
162        let cfg = Config::from_toml_str(
163            r#"
164            provider_type = "openai"
165            default_model = "phi-4-npu-ov"
166            "#,
167        )
168        .unwrap();
169        assert_eq!(cfg.provider.as_deref(), Some("openai"));
170        assert_eq!(cfg.model.as_deref(), Some("phi-4-npu-ov"));
171    }
172
173    #[test]
174    fn missing_file_returns_default() {
175        let path = Path::new("/nonexistent/perspt/config.toml");
176        let cfg = Config::load_from_path(path).unwrap();
177        assert!(cfg.provider.is_none());
178    }
179
180    #[test]
181    fn masked_hides_api_key() {
182        let cfg = Config {
183            api_key: Some("super-secret".to_string()),
184            ..Default::default()
185        };
186        assert_eq!(cfg.masked().api_key.as_deref(), Some("***"));
187    }
188
189    #[test]
190    fn masked_leaves_absent_key_absent() {
191        let cfg = Config::default();
192        assert!(cfg.masked().api_key.is_none());
193    }
194
195    #[test]
196    fn set_value_updates_known_keys() {
197        let mut cfg = Config::default();
198        cfg.set_value("default_model", "phi-4-npu-ov").unwrap();
199        assert_eq!(cfg.model.as_deref(), Some("phi-4-npu-ov"));
200        cfg.set_value("provider", "openai").unwrap();
201        assert_eq!(cfg.provider.as_deref(), Some("openai"));
202        cfg.set_value("vertex_project_id", "test-project").unwrap();
203        cfg.set_value("vertex_location", "test-location").unwrap();
204        assert_eq!(cfg.vertex_project_id.as_deref(), Some("test-project"));
205        assert_eq!(cfg.vertex_location.as_deref(), Some("test-location"));
206    }
207
208    #[test]
209    fn set_value_rejects_unknown_key() {
210        let mut cfg = Config::default();
211        assert!(cfg.set_value("nope", "x").is_err());
212    }
213
214    #[test]
215    fn round_trip_set_does_not_duplicate() {
216        let mut cfg = Config::default();
217        cfg.set_value("default_model", "a").unwrap();
218        cfg.set_value("default_model", "b").unwrap();
219        let serialized = cfg.to_toml_string().unwrap();
220        // Exactly one model line after two sets.
221        assert_eq!(serialized.matches("model").count(), 1);
222        let reparsed = Config::from_toml_str(&serialized).unwrap();
223        assert_eq!(reparsed.model.as_deref(), Some("b"));
224    }
225}