Skip to main content

pipedash_core/infrastructure/config/
analysis.rs

1use 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}