pipedash_core/infrastructure/migration/
validator.rs1use std::collections::{
2 HashMap,
3 HashSet,
4};
5use std::path::Path;
6
7use super::MigrationOptions;
8use crate::domain::{
9 DomainError,
10 DomainResult,
11};
12use crate::infrastructure::config::{
13 PipedashConfig,
14 StorageBackend,
15};
16use crate::infrastructure::StorageManager;
17
18#[derive(Debug, Clone)]
19pub struct PreMigrationValidationReport {
20 pub passed: bool,
21 pub errors: Vec<String>,
22 pub warnings: Vec<String>,
23 pub estimated_backup_size: u64,
24}
25
26impl PreMigrationValidationReport {
27 pub fn new() -> Self {
28 Self {
29 passed: true,
30 errors: Vec::new(),
31 warnings: Vec::new(),
32 estimated_backup_size: 0,
33 }
34 }
35
36 pub fn add_error(&mut self, error: String) {
37 self.errors.push(error);
38 self.passed = false;
39 }
40
41 pub fn add_warning(&mut self, warning: String) {
42 self.warnings.push(warning);
43 }
44
45 pub fn into_result(self) -> DomainResult<Self> {
46 if self.passed {
47 Ok(self)
48 } else {
49 Err(DomainError::InvalidConfig(format!(
50 "Pre-migration validation failed:\n{}",
51 self.errors.join("\n")
52 )))
53 }
54 }
55}
56
57impl Default for PreMigrationValidationReport {
58 fn default() -> Self {
59 Self::new()
60 }
61}
62
63pub struct PreMigrationValidator;
64
65impl PreMigrationValidator {
66 pub async fn validate_all(
67 source_config: &PipedashConfig, target_config: &PipedashConfig,
68 source_manager: &StorageManager, target_manager: &StorageManager,
69 options: &MigrationOptions,
70 ) -> DomainResult<PreMigrationValidationReport> {
71 let mut report = PreMigrationValidationReport::new();
72
73 Self::validate_disk_space(source_config, &mut report).await;
74
75 Self::validate_provider_names(source_manager, &mut report).await;
76
77 Self::validate_no_orphaned_tokens(source_manager, &mut report).await;
78
79 Self::validate_target_state(target_manager, options, &mut report).await;
80
81 if target_config.storage.backend == StorageBackend::Postgres {
82 Self::validate_postgres_schema(target_config, &mut report).await;
83 }
84
85 report.into_result()
86 }
87
88 async fn validate_disk_space(
89 source_config: &PipedashConfig, report: &mut PreMigrationValidationReport,
90 ) {
91 use crate::infrastructure::migration::backup::BackupManager;
92
93 let backup_manager = BackupManager::new(source_config.data_dir());
94 let estimated_size = backup_manager.estimate_backup_size(source_config).await;
95 report.estimated_backup_size = estimated_size;
96
97 let data_dir = source_config.data_dir();
98 if let Ok(available) = Self::get_available_disk_space(&data_dir) {
99 let required = estimated_size * 2; if available < required {
102 report.add_error(format!(
103 "Insufficient disk space for migration backup.\n\
104 Required: {} MB\n\
105 Available: {} MB\n\
106 Please free up disk space before continuing.",
107 required / 1_048_576,
108 available / 1_048_576
109 ));
110 } else if available < required * 2 {
111 report.add_warning(format!(
112 "Low disk space detected.\n\
113 Required: {} MB\n\
114 Available: {} MB\n\
115 Consider freeing up space for safety.",
116 required / 1_048_576,
117 available / 1_048_576
118 ));
119 }
120 } else {
121 report.add_warning("Could not check disk space - proceeding with caution".to_string());
122 }
123 }
124
125 fn get_available_disk_space(path: &Path) -> std::io::Result<u64> {
126 #[cfg(unix)]
127 {
128 use std::os::unix::fs::MetadataExt;
129 let metadata = std::fs::metadata(path)?;
130 Ok(metadata.blocks() * 512)
131 }
132
133 #[cfg(not(unix))]
134 {
135 let _ = path;
136 Ok(u64::MAX)
137 }
138 }
139
140 async fn validate_provider_names(
141 source_manager: &StorageManager, report: &mut PreMigrationValidationReport,
142 ) {
143 match source_manager.config_backend().list_providers().await {
144 Ok(providers) => {
145 let mut names = HashSet::new();
146 let mut duplicates = Vec::new();
147
148 for provider in &providers {
149 if !names.insert(provider.name.clone()) {
150 duplicates.push(provider.name.clone());
151 }
152 }
153
154 if !duplicates.is_empty() {
155 report.add_error(format!(
156 "Duplicate provider names detected: {:?}\n\n\
157 Provider names must be unique for migration to work correctly.\n\
158 Please rename or remove duplicate providers before migrating.",
159 duplicates
160 ));
161 }
162
163 if providers.is_empty() {
164 report.add_warning("No providers found in source database".to_string());
165 }
166 }
167 Err(e) => {
168 report.add_error(format!("Failed to list source providers: {}", e));
169 }
170 }
171 }
172
173 async fn validate_no_orphaned_tokens(
174 source_manager: &StorageManager, report: &mut PreMigrationValidationReport,
175 ) {
176 let providers = match source_manager.config_backend().list_providers().await {
177 Ok(p) => p,
178 Err(e) => {
179 report.add_error(format!("Failed to list providers: {}", e));
180 return;
181 }
182 };
183
184 let tokens = match source_manager.token_store().await.get_all_tokens().await {
185 Ok(t) => t,
186 Err(e) => {
187 report.add_error(format!("Failed to list tokens: {}", e));
188 return;
189 }
190 };
191
192 let provider_ids: HashSet<i64> = providers.iter().filter_map(|p| p.id).collect();
193 let orphaned: Vec<i64> = tokens
194 .keys()
195 .filter(|id| !provider_ids.contains(id))
196 .copied()
197 .collect();
198
199 if !orphaned.is_empty() {
200 report.add_error(format!(
201 "Found {} orphaned token(s) without corresponding providers: {:?}\n\n\
202 This indicates data corruption or inconsistency.\n\
203 Please investigate and remove orphaned tokens before migrating:\n\
204 - Provider IDs with tokens: {:?}\n\
205 - Valid provider IDs: {:?}",
206 orphaned.len(),
207 orphaned,
208 tokens.keys().collect::<Vec<_>>(),
209 provider_ids
210 ));
211 }
212 }
213
214 async fn validate_target_state(
215 target_manager: &StorageManager, options: &MigrationOptions,
216 report: &mut PreMigrationValidationReport,
217 ) {
218 let providers = match target_manager.config_backend().list_providers().await {
219 Ok(p) => p,
220 Err(e) => {
221 tracing::debug!("Target database not accessible (may not exist yet): {}", e);
222 return;
223 }
224 };
225
226 let tokens = match target_manager.token_store().await.get_all_tokens().await {
227 Ok(t) => t,
228 Err(e) => {
229 tracing::debug!("Target tokens not accessible: {}", e);
230 HashMap::new()
231 }
232 };
233
234 if !providers.is_empty() || !tokens.is_empty() {
235 if !options.allow_non_empty_target {
236 report.add_error(format!(
237 "Target database is not empty:\n\
238 - {} providers\n\
239 - {} tokens\n\n\
240 To prevent accidental data loss, migration requires an empty target database.\n\n\
241 Options:\n\
242 1. Use a different target database\n\
243 2. Clear the target database manually\n\
244 3. Set allow_non_empty_target=true to merge data (advanced)",
245 providers.len(),
246 tokens.len()
247 ));
248 } else {
249 report.add_warning(format!(
250 "Target database is not empty ({} providers, {} tokens).\n\
251 Migration will attempt to merge data.\n\
252 Conflicts may occur if provider names overlap.",
253 providers.len(),
254 tokens.len()
255 ));
256 }
257 }
258 }
259
260 async fn validate_postgres_schema(
261 _target_config: &PipedashConfig, report: &mut PreMigrationValidationReport,
262 ) {
263 report.add_warning(
264 "PostgreSQL schema validation not yet implemented.\n\
265 Ensure you've run all database migrations before migrating data."
266 .to_string(),
267 );
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 #[test]
276 fn test_validation_report() {
277 let mut report = PreMigrationValidationReport::new();
278 assert!(report.passed);
279 assert!(report.errors.is_empty());
280
281 report.add_warning("Test warning".to_string());
282 assert!(report.passed);
283 assert_eq!(report.warnings.len(), 1);
284
285 report.add_error("Test error".to_string());
286 assert!(!report.passed);
287 assert_eq!(report.errors.len(), 1);
288 }
289
290 #[test]
291 fn test_validation_report_into_result() {
292 let mut report = PreMigrationValidationReport::new();
293 assert!(report.clone().into_result().is_ok());
294
295 report.add_error("Test error".to_string());
296 assert!(report.into_result().is_err());
297 }
298}