Skip to main content

pipedash_core/infrastructure/migration/
validator.rs

1use 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; // Need 2x for safety margin
100
101            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}