pipedash_core/infrastructure/config/
analysis.rs1use std::sync::Arc;
2
3use serde::Serialize;
4
5use super::{
6 ConfigLoader,
7 PipedashConfig,
8 StorageBackend,
9 StorageManager,
10};
11use crate::domain::{
12 DomainError,
13 DomainResult,
14};
15use crate::event::EventBus;
16use crate::infrastructure::migration::{
17 MigrationOptions,
18 MigrationOrchestrator,
19 MigrationPlan,
20};
21use crate::infrastructure::token_store::TokenStore;
22
23#[derive(Debug, Clone, Serialize)]
24pub struct ConfigIssue {
25 pub field: String,
26 pub message: String,
27 pub code: String,
28}
29
30#[derive(Debug, Serialize)]
31pub struct ConnectionTestResult {
32 pub success: bool,
33 pub message: String,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub latency_ms: Option<u64>,
36}
37
38#[derive(Debug, Serialize)]
39pub struct ConfigDataStats {
40 pub providers_count: usize,
41 pub tokens_count: usize,
42 pub cache_entries_count: usize,
43}
44
45#[derive(Debug, Serialize)]
46pub struct ConfigAnalysis {
47 pub valid: bool,
48 pub errors: Vec<ConfigIssue>,
49 pub warnings: Vec<ConfigIssue>,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub migration_plan: Option<MigrationPlan>,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub postgres_connection: Option<ConnectionTestResult>,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub stats: Option<ConfigDataStats>,
56}
57
58pub async fn analyze_config(
59 new_content: &str, storage_manager: &StorageManager, event_bus: Option<Arc<dyn EventBus>>,
60 target_token_store: Option<Arc<dyn TokenStore>>,
61) -> DomainResult<ConfigAnalysis> {
62 let new_config = ConfigLoader::parse(new_content).map_err(|_| {
63 DomainError::InvalidConfig("Invalid config fields or environment references".to_string())
64 })?;
65
66 let validation = new_config.validate();
67
68 let errors = validation
69 .errors
70 .iter()
71 .map(|e| ConfigIssue {
72 field: e.field.clone(),
73 message: e.message.clone(),
74 code: e.code.to_string(),
75 })
76 .collect();
77
78 let warnings = validation
79 .warnings
80 .iter()
81 .map(|w| ConfigIssue {
82 field: w.field.clone(),
83 message: w.message.clone(),
84 code: w.code.to_string(),
85 })
86 .collect();
87
88 let current_config = storage_manager.config();
89
90 let (migration_plan, stats) = if backends_changed(current_config, &new_config) {
91 let orchestrator =
92 MigrationOrchestrator::from_manager(storage_manager, event_bus, target_token_store)
93 .await;
94 let plan = orchestrator.plan_migration(new_config.clone(), &MigrationOptions::default())?;
95
96 let providers = storage_manager.config_backend().list_providers().await?;
97 let tokens = storage_manager.token_store().await.get_all_tokens().await?;
98 let cache_entries = storage_manager.cache_backend().list(None).await?;
99
100 let stats = ConfigDataStats {
101 providers_count: providers.len(),
102 tokens_count: tokens.len(),
103 cache_entries_count: cache_entries.len(),
104 };
105
106 (Some(plan), Some(stats))
107 } else {
108 (None, None)
109 };
110
111 let postgres_connection = if should_test_postgres_connection(current_config, &new_config) {
112 Some(test_postgres_connection(&new_config.storage.postgres.connection_string).await)
113 } else {
114 None
115 };
116
117 Ok(ConfigAnalysis {
118 valid: validation.is_ok(),
119 errors,
120 warnings,
121 migration_plan,
122 postgres_connection,
123 stats,
124 })
125}
126
127fn backends_changed(old: &PipedashConfig, new: &PipedashConfig) -> bool {
128 old.storage.backend != new.storage.backend || old.data_dir() != new.data_dir()
129}
130
131fn should_test_postgres_connection(old: &PipedashConfig, new: &PipedashConfig) -> bool {
132 let uses_postgres = new.storage.backend == StorageBackend::Postgres;
133
134 let connection_changed =
135 old.storage.postgres.connection_string != new.storage.postgres.connection_string;
136
137 let switching_to_postgres = old.storage.backend != StorageBackend::Postgres
138 && new.storage.backend == StorageBackend::Postgres;
139
140 uses_postgres && (connection_changed || switching_to_postgres)
141}
142
143async fn test_postgres_connection(connection_string: &str) -> ConnectionTestResult {
144 #[cfg(feature = "postgres")]
145 {
146 use std::time::Instant;
147
148 use sqlx::postgres::PgPoolOptions;
149
150 if connection_string.is_empty() {
151 return ConnectionTestResult {
152 success: false,
153 message: "PostgreSQL connection string is empty".to_string(),
154 latency_ms: None,
155 };
156 }
157
158 let start = Instant::now();
159
160 let timeout_duration = std::time::Duration::from_secs(5);
161 let connect_result = tokio::time::timeout(timeout_duration, async {
162 let pool = PgPoolOptions::new()
163 .max_connections(1)
164 .acquire_timeout(std::time::Duration::from_secs(3))
165 .connect(connection_string)
166 .await?;
167
168 sqlx::query("SELECT 1").execute(&pool).await?;
169
170 Ok::<_, sqlx::Error>(pool)
171 })
172 .await;
173
174 match connect_result {
175 Ok(Ok(_pool)) => {
176 let latency = start.elapsed().as_millis() as u64;
177 ConnectionTestResult {
178 success: true,
179 message: "Successfully connected to PostgreSQL".to_string(),
180 latency_ms: Some(latency),
181 }
182 }
183 Ok(Err(e)) => ConnectionTestResult {
184 success: false,
185 message: format!("Failed to connect to PostgreSQL: {}", e),
186 latency_ms: None,
187 },
188 Err(_) => ConnectionTestResult {
189 success: false,
190 message: format!(
191 "Connection timeout after {} seconds",
192 timeout_duration.as_secs()
193 ),
194 latency_ms: None,
195 },
196 }
197 }
198
199 #[cfg(not(feature = "postgres"))]
200 {
201 let _ = connection_string;
202 ConnectionTestResult {
203 success: false,
204 message: "PostgreSQL feature not enabled. Compile with --features postgres".to_string(),
205 latency_ms: None,
206 }
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use tempfile::TempDir;
213
214 use super::*;
215 use crate::infrastructure::token_store::MemoryTokenStore;
216
217 async fn sqlite_manager(data_dir: &TempDir) -> StorageManager {
218 let mut config = PipedashConfig::default();
219 config.storage.data_dir = data_dir.path().to_string_lossy().to_string();
220
221 StorageManager::with_token_store(config, Arc::new(MemoryTokenStore::new()), true)
222 .await
223 .unwrap()
224 }
225
226 #[tokio::test]
227 async fn switching_sqlite_to_postgres_returns_a_migration_plan() {
228 let data_dir = TempDir::new().unwrap();
229 let manager = sqlite_manager(&data_dir).await;
230 let content = format!(
231 "[storage]\ndata_dir = {:?}\nbackend = \"postgres\"\n\n[storage.postgres]\nconnection_string = \"postgres://user:pass@127.0.0.1:1/pipedash\"\n",
232 data_dir.path().to_string_lossy()
233 );
234
235 let analysis = analyze_config(&content, &manager, None, None)
236 .await
237 .unwrap();
238
239 let plan = analysis.migration_plan.expect("migration plan");
240 assert_eq!(plan.to.storage.backend, StorageBackend::Postgres);
241 assert!(!plan.steps.is_empty());
242 assert!(analysis.stats.is_some());
243 assert!(
244 !analysis
245 .postgres_connection
246 .expect("connection test")
247 .success
248 );
249 }
250
251 #[tokio::test]
252 async fn unchanged_storage_returns_no_migration_plan() {
253 let data_dir = TempDir::new().unwrap();
254 let manager = sqlite_manager(&data_dir).await;
255 let content = format!(
256 "[storage]\ndata_dir = {:?}\nbackend = \"sqlite\"\n",
257 data_dir.path().to_string_lossy()
258 );
259
260 let analysis = analyze_config(&content, &manager, None, None)
261 .await
262 .unwrap();
263
264 assert!(analysis.valid);
265 assert!(analysis.migration_plan.is_none());
266 assert!(analysis.postgres_connection.is_none());
267 }
268
269 #[tokio::test]
270 async fn analysis_expands_backend_references_before_validation() {
271 let data_dir = TempDir::new().unwrap();
272 let manager = sqlite_manager(&data_dir).await;
273 let content = format!(
274 "[storage]\ndata_dir = {:?}\nbackend = \"${{PIPEDASH_ANALYSIS_TEST_BACKEND:-sqlite}}\"\n",
275 data_dir.path().to_string_lossy()
276 );
277
278 let analysis = analyze_config(&content, &manager, None, None)
279 .await
280 .unwrap();
281
282 assert!(analysis.valid);
283 assert!(analysis.migration_plan.is_none());
284 assert!(analysis.postgres_connection.is_none());
285 }
286
287 #[tokio::test]
288 async fn analysis_parse_errors_do_not_include_expanded_values() {
289 let data_dir = TempDir::new().unwrap();
290 let manager = sqlite_manager(&data_dir).await;
291 let content =
292 "[storage]\nbackend = \"${PIPEDASH_ANALYSIS_TEST_SECRET:-editor-secret-value}\"\n";
293
294 let error = analyze_config(content, &manager, None, None)
295 .await
296 .unwrap_err();
297
298 assert!(matches!(error, DomainError::InvalidConfig(_)));
299 assert!(!error.to_string().contains("editor-secret-value"));
300 }
301
302 #[tokio::test]
303 async fn invalid_toml_is_an_invalid_config_error() {
304 let data_dir = TempDir::new().unwrap();
305 let manager = sqlite_manager(&data_dir).await;
306
307 let err = analyze_config("[storage", &manager, None, None)
308 .await
309 .unwrap_err();
310
311 assert!(matches!(err, DomainError::InvalidConfig(_)));
312 }
313}