1use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10
11const MASKED_API_KEY: &str = "***";
13
14#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20#[serde(default)]
21pub struct Config {
22 #[serde(
24 alias = "provider_type",
25 alias = "default_provider",
26 skip_serializing_if = "Option::is_none"
27 )]
28 pub provider: Option<String>,
29
30 #[serde(alias = "default_model", skip_serializing_if = "Option::is_none")]
32 pub model: Option<String>,
33
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub api_key: Option<String>,
37
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub base_url: Option<String>,
41
42 #[serde(skip_serializing_if = "Option::is_none")]
45 pub vertex_project_id: Option<String>,
46
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub vertex_location: Option<String>,
50
51 #[serde(
57 skip_serializing_if = "Option::is_none",
58 alias = "python_package_manager"
59 )]
60 pub package_manager: Option<String>,
61
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub architect_model: Option<String>,
65
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub actuator_model: Option<String>,
69
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub verifier_model: Option<String>,
73
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub speculator_model: Option<String>,
77}
78
79impl Config {
80 pub fn from_toml_str(content: &str) -> Result<Self> {
82 toml::from_str(content).context("Failed to parse TOML configuration")
83 }
84
85 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 pub fn to_toml_string(&self) -> Result<String> {
98 toml::to_string_pretty(self).context("Failed to serialize configuration to TOML")
99 }
100
101 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 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 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 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}