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")]
44 pub architect_model: Option<String>,
45
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub actuator_model: Option<String>,
49
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub verifier_model: Option<String>,
53
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub speculator_model: Option<String>,
57}
58
59impl Config {
60 pub fn from_toml_str(content: &str) -> Result<Self> {
62 toml::from_str(content).context("Failed to parse TOML configuration")
63 }
64
65 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 pub fn to_toml_string(&self) -> Result<String> {
78 toml::to_string_pretty(self).context("Failed to serialize configuration to TOML")
79 }
80
81 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 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 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}