spec_ai/spec_ai_config/config/
cache.rs1use anyhow::{Context, Result};
2use serde_json;
3
4use super::AppConfig;
5use crate::spec_ai_config::persistence::Persistence;
6
7const CONFIG_CACHE_KEY: &str = "effective_config";
8const POLICIES_CACHE_KEY: &str = "effective_policies";
9
10pub struct ConfigCache {
12 persistence: Persistence,
13}
14
15impl ConfigCache {
16 pub fn new(persistence: Persistence) -> Self {
18 Self { persistence }
19 }
20
21 pub fn store_effective_config(&self, config: &AppConfig) -> Result<()> {
23 let value = serde_json::to_value(config).context("serializing config to JSON")?;
24
25 self.persistence
26 .policy_upsert(CONFIG_CACHE_KEY, &value)
27 .context("storing effective config in cache")
28 }
29
30 pub fn load_effective_config(&self) -> Result<Option<AppConfig>> {
32 if let Some(entry) = self.persistence.policy_get(CONFIG_CACHE_KEY)? {
33 let config: AppConfig =
34 serde_json::from_value(entry.value).context("deserializing cached config")?;
35 Ok(Some(config))
36 } else {
37 Ok(None)
38 }
39 }
40
41 pub fn store_effective_policies(&self, policies: &serde_json::Value) -> Result<()> {
43 self.persistence
44 .policy_upsert(POLICIES_CACHE_KEY, policies)
45 .context("storing effective policies in cache")
46 }
47
48 pub fn load_effective_policies(&self) -> Result<Option<serde_json::Value>> {
50 if let Some(entry) = self.persistence.policy_get(POLICIES_CACHE_KEY)? {
51 Ok(Some(entry.value))
52 } else {
53 Ok(None)
54 }
55 }
56
57 pub fn has_config_changed(&self, current: &AppConfig) -> Result<bool> {
60 if let Some(cached) = self.load_effective_config()? {
61 let current_json =
63 serde_json::to_value(current).context("serializing current config")?;
64 let cached_json = serde_json::to_value(&cached).context("serializing cached config")?;
65
66 Ok(current_json != cached_json)
67 } else {
68 Ok(true)
70 }
71 }
72
73 pub fn diff_summary(&self, current: &AppConfig) -> Result<Vec<String>> {
75 let mut changes = Vec::new();
76
77 if let Some(cached) = self.load_effective_config()? {
78 if current.model.provider != cached.model.provider {
80 changes.push(format!(
81 "Model provider: {} -> {}",
82 cached.model.provider, current.model.provider
83 ));
84 }
85
86 if current.model.temperature != cached.model.temperature {
87 changes.push(format!(
88 "Temperature: {} -> {}",
89 cached.model.temperature, current.model.temperature
90 ));
91 }
92
93 if current.logging.level != cached.logging.level {
94 changes.push(format!(
95 "Logging level: {} -> {}",
96 cached.logging.level, current.logging.level
97 ));
98 }
99
100 if current.database.path != cached.database.path {
101 changes.push(format!(
102 "Database path: {} -> {}",
103 cached.database.path.display(),
104 current.database.path.display()
105 ));
106 }
107
108 if current.agents.len() != cached.agents.len() {
109 changes.push(format!(
110 "Number of agents: {} -> {}",
111 cached.agents.len(),
112 current.agents.len()
113 ));
114 }
115
116 if current.default_agent != cached.default_agent {
117 changes.push(format!(
118 "Default agent: {:?} -> {:?}",
119 cached.default_agent, current.default_agent
120 ));
121 }
122 } else {
123 changes.push("No cached config found (first run or cache cleared)".to_string());
124 }
125
126 Ok(changes)
127 }
128
129 pub fn clear(&self) -> Result<()> {
131 self.persistence
133 .policy_upsert(CONFIG_CACHE_KEY, &serde_json::Value::Null)?;
134 self.persistence
135 .policy_upsert(POLICIES_CACHE_KEY, &serde_json::Value::Null)?;
136 Ok(())
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use tempfile::TempDir;
144
145 fn create_test_config() -> AppConfig {
146 use crate::spec_ai_config::config::{
147 AudioConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig, PluginConfig,
148 SafetyConfig, SyncConfig, UiConfig,
149 };
150 use std::collections::HashMap;
151 use std::path::PathBuf;
152
153 AppConfig {
154 database: DatabaseConfig {
155 path: PathBuf::from("/tmp/test.db"),
156 },
157 model: ModelConfig {
158 provider: "test".to_string(),
159 model_name: None,
160 code_model: None,
161 embeddings_model: None,
162 api_key_source: None,
163 temperature: 0.5,
164 },
165 ui: UiConfig {
166 prompt: "> ".to_string(),
167 theme: "default".to_string(),
168 },
169 logging: LoggingConfig {
170 level: "info".to_string(),
171 },
172 audio: AudioConfig::default(),
173 mesh: crate::spec_ai_config::config::MeshConfig::default(),
174 plugins: PluginConfig::default(),
175 sync: SyncConfig::default(),
176 skills: crate::spec_ai_config::config::SkillsConfig::default(),
177 mcp: crate::spec_ai_config::config::McpConfig::default(),
178 auth: AuthConfig::default(),
179 safety: SafetyConfig::default(),
180 approval: Default::default(),
181 agents: HashMap::new(),
182 default_agent: None,
183 }
184 }
185
186 #[test]
187 fn test_store_and_load_config() {
188 let temp_dir = TempDir::new().unwrap();
189 let db_path = temp_dir.path().join("test.duckdb");
190 let persistence = Persistence::new(&db_path).unwrap();
191 let cache = ConfigCache::new(persistence);
192
193 let config = create_test_config();
194
195 cache.store_effective_config(&config).unwrap();
197
198 let loaded = cache.load_effective_config().unwrap();
200 assert!(loaded.is_some());
201
202 let loaded_config = loaded.unwrap();
203 assert_eq!(loaded_config.model.provider, "test");
204 assert_eq!(loaded_config.model.temperature, 0.5);
205 }
206
207 #[test]
208 fn test_load_nonexistent_config() {
209 let temp_dir = TempDir::new().unwrap();
210 let db_path = temp_dir.path().join("test.duckdb");
211 let persistence = Persistence::new(&db_path).unwrap();
212 let cache = ConfigCache::new(persistence);
213
214 let loaded = cache.load_effective_config().unwrap();
215 assert!(loaded.is_none());
216 }
217
218 #[test]
219 fn test_store_and_load_policies() {
220 let temp_dir = TempDir::new().unwrap();
221 let db_path = temp_dir.path().join("test.duckdb");
222 let persistence = Persistence::new(&db_path).unwrap();
223 let cache = ConfigCache::new(persistence);
224
225 let policies = serde_json::json!({
226 "allow": ["tool1", "tool2"],
227 "deny": ["tool3"]
228 });
229
230 cache.store_effective_policies(&policies).unwrap();
232
233 let loaded = cache.load_effective_policies().unwrap();
235 assert!(loaded.is_some());
236 assert_eq!(loaded.unwrap(), policies);
237 }
238
239 #[test]
240 fn test_has_config_changed() {
241 let temp_dir = TempDir::new().unwrap();
242 let db_path = temp_dir.path().join("test.duckdb");
243 let persistence = Persistence::new(&db_path).unwrap();
244 let cache = ConfigCache::new(persistence);
245
246 let config1 = create_test_config();
247
248 assert!(cache.has_config_changed(&config1).unwrap());
250
251 cache.store_effective_config(&config1).unwrap();
253
254 assert!(!cache.has_config_changed(&config1).unwrap());
256
257 let mut config2 = config1.clone();
259 config2.model.temperature = 0.9;
260
261 assert!(cache.has_config_changed(&config2).unwrap());
263 }
264
265 #[test]
266 fn test_diff_summary() {
267 let temp_dir = TempDir::new().unwrap();
268 let db_path = temp_dir.path().join("test.duckdb");
269 let persistence = Persistence::new(&db_path).unwrap();
270 let cache = ConfigCache::new(persistence);
271
272 let mut config1 = create_test_config();
273 cache.store_effective_config(&config1).unwrap();
274
275 config1.model.provider = "new_provider".to_string();
277 config1.model.temperature = 0.9;
278
279 let diff = cache.diff_summary(&config1).unwrap();
280 assert!(diff.len() >= 2);
281 assert!(diff.iter().any(|s| s.contains("Model provider")));
282 assert!(diff.iter().any(|s| s.contains("Temperature")));
283 }
284
285 #[test]
286 fn test_clear_cache() {
287 let temp_dir = TempDir::new().unwrap();
288 let db_path = temp_dir.path().join("test.duckdb");
289 let persistence = Persistence::new(&db_path).unwrap();
290 let cache = ConfigCache::new(persistence);
291
292 let config = create_test_config();
293 let policies = serde_json::json!({"test": "value"});
294
295 cache.store_effective_config(&config).unwrap();
297 cache.store_effective_policies(&policies).unwrap();
298
299 assert!(cache.load_effective_config().unwrap().is_some());
301 assert!(cache.load_effective_policies().unwrap().is_some());
302
303 cache.clear().unwrap();
305
306 let _ = cache.load_effective_config();
310 }
311
312 #[test]
313 fn test_idempotent_store() {
314 let temp_dir = TempDir::new().unwrap();
315 let db_path = temp_dir.path().join("test.duckdb");
316 let persistence = Persistence::new(&db_path).unwrap();
317 let cache = ConfigCache::new(persistence);
318
319 let config = create_test_config();
320
321 cache.store_effective_config(&config).unwrap();
323 cache.store_effective_config(&config).unwrap();
324 cache.store_effective_config(&config).unwrap();
325
326 let loaded = cache.load_effective_config().unwrap();
328 assert!(loaded.is_some());
329 assert_eq!(loaded.unwrap().model.provider, "test");
330 }
331}