1pub mod backup;
2pub mod validator;
3
4use std::collections::HashMap;
5use std::sync::Arc;
6
7pub use backup::{
8 BackupManager,
9 BackupPaths,
10};
11use chrono::{
12 DateTime,
13 Utc,
14};
15use serde::{
16 Deserialize,
17 Serialize,
18};
19pub use validator::{
20 PreMigrationValidationReport,
21 PreMigrationValidator,
22};
23
24use crate::domain::{
25 DomainError,
26 DomainResult,
27 ProviderConfig,
28};
29use crate::event::EventBus;
30use crate::infrastructure::config::{
31 PipedashConfig,
32 StorageConfig,
33 StorageManager,
34};
35use crate::infrastructure::token_store::TokenStore;
36use crate::infrastructure::{
37 ConfigBackend,
38 StorageBackend,
39};
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42pub enum MigrationStep {
43 ValidateTarget,
44 MigrateTokens,
45 MigrateConfigs,
46 MigrateCache,
47 VerifyMigration,
48 UpdateConfig,
49}
50
51impl MigrationStep {
52 pub fn description(&self) -> &str {
53 match self {
54 MigrationStep::ValidateTarget => "Validating target configuration",
55 MigrationStep::MigrateTokens => "Migrating tokens",
56 MigrationStep::MigrateConfigs => "Migrating provider configurations",
57 MigrationStep::MigrateCache => "Migrating cached data",
58 MigrationStep::VerifyMigration => "Verifying migration",
59 MigrationStep::UpdateConfig => "Updating storage configuration",
60 }
61 }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct MigrationPlan {
66 pub from: PipedashConfig,
67 pub to: PipedashConfig,
68 pub steps: Vec<MigrationStep>,
69 pub migrate_tokens: bool,
70 pub migrate_configs: bool,
71 pub migrate_cache: bool,
72 pub backend_changed: bool,
73 pub data_dir_changed: bool,
74 pub created_at: DateTime<Utc>,
75}
76
77impl MigrationPlan {
78 pub fn summary(&self) -> String {
79 format!(
80 "Migrate from {} to {} ({} steps)",
81 self.from.storage.summary(),
82 self.to.storage.summary(),
83 self.steps.len()
84 )
85 }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct MigrationProgress {
90 pub current_step: MigrationStep,
91 pub step_index: usize,
92 pub total_steps: usize,
93 pub message: String,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct MigrationResult {
98 pub success: bool,
99 pub steps_completed: Vec<MigrationStep>,
100 pub errors: Vec<String>,
101 pub duration_ms: u64,
102 pub stats: MigrationStats,
103 #[serde(default)]
104 pub provider_id_mapping: std::collections::HashMap<i64, i64>,
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub backups: Option<BackupPaths>,
107}
108
109impl MigrationResult {
110 pub fn summary(&self) -> String {
111 if self.success {
112 format!(
113 "Migration successful: {} providers, {} cache entries migrated in {}ms",
114 self.stats.providers_migrated, self.stats.cache_entries_migrated, self.duration_ms
115 )
116 } else {
117 format!(
118 "Migration failed after {} steps: {}",
119 self.steps_completed.len(),
120 self.errors.join(", ")
121 )
122 }
123 }
124}
125
126#[derive(Debug, Clone, Default, Serialize, Deserialize)]
127pub struct MigrationStats {
128 pub providers_migrated: usize,
129 pub tokens_migrated: usize,
130 pub cache_entries_migrated: usize,
131 pub permissions_migrated: usize,
132 pub providers_cleaned: usize,
133 pub tokens_cleaned: usize,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct MigrationOptions {
138 pub migrate_tokens: bool,
139 pub migrate_cache: bool,
140 pub token_password: Option<String>,
141 pub dry_run: bool,
142 #[serde(default)]
143 pub allow_non_empty_target: bool,
144 #[serde(default)]
145 pub clean_target: bool,
146 #[serde(default)]
147 pub keep_backups: bool,
148 #[serde(default = "default_max_retries")]
149 pub max_retries: u32,
150}
151
152fn default_max_retries() -> u32 {
153 3
154}
155
156impl Default for MigrationOptions {
157 fn default() -> Self {
158 Self {
159 migrate_tokens: false,
160 migrate_cache: false,
161 token_password: None,
162 dry_run: false,
163 allow_non_empty_target: false,
164 clean_target: false,
165 keep_backups: false,
166 max_retries: 3,
167 }
168 }
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct ValidationReport {
173 pub errors: Vec<String>,
174 pub warnings: Vec<String>,
175 pub recommendations: Vec<String>,
176}
177
178impl ValidationReport {
179 pub fn is_valid(&self) -> bool {
180 self.errors.is_empty()
181 }
182}
183
184pub const VAULT_PASSWORD_ENV_VAR: &str = "PIPEDASH_VAULT_PASSWORD";
185
186fn get_vault_password(options: &MigrationOptions) -> DomainResult<String> {
187 if let Ok(password) = std::env::var(VAULT_PASSWORD_ENV_VAR) {
188 if !password.is_empty() {
189 return Ok(password);
190 }
191 }
192
193 options.token_password.clone().ok_or_else(|| {
194 DomainError::InvalidConfig(format!(
195 "Vault password required for token migration. Set {} environment variable or provide token_password in options.",
196 VAULT_PASSWORD_ENV_VAR
197 ))
198 })
199}
200
201pub struct MigrationOrchestrator {
202 source_config: PipedashConfig,
203 source_token_store: Arc<dyn TokenStore>,
204 source_config_backend: Arc<dyn ConfigBackend>,
205 source_cache_backend: Arc<dyn StorageBackend>,
206 event_bus: Option<Arc<dyn EventBus>>,
207 target_token_store: Option<Arc<dyn TokenStore>>,
208}
209
210impl MigrationOrchestrator {
211 pub async fn from_manager(
212 manager: &StorageManager, event_bus: Option<Arc<dyn EventBus>>,
213 target_token_store: Option<Arc<dyn TokenStore>>,
214 ) -> Self {
215 Self {
216 source_config: manager.config().clone(),
217 source_token_store: manager.token_store().await,
218 source_config_backend: manager.config_backend(),
219 source_cache_backend: manager.cache_backend(),
220 event_bus,
221 target_token_store,
222 }
223 }
224
225 pub fn plan_migration(
226 &self, mut target_config: PipedashConfig, options: &MigrationOptions,
227 ) -> DomainResult<MigrationPlan> {
228 use crate::infrastructure::config::StorageBackend as ConfigStorageBackend;
229
230 let source_data_dir = self.source_config.data_dir();
231 let target_data_dir = target_config.data_dir();
232 let data_dir_changed = source_data_dir != target_data_dir;
233
234 let source_storage = &self.source_config.storage;
235 let target_storage = &mut target_config.storage;
236
237 if data_dir_changed {
238 tracing::info!(
239 "[Migration Planning] Data directory change detected: {:?} → {:?}",
240 source_data_dir,
241 target_data_dir
242 );
243 }
244
245 let backend_changed = source_storage.backend != target_storage.backend;
246
247 let token_backend_changing = {
248 let source_uses_keyring = source_storage.backend.is_sqlite()
249 && std::env::var(VAULT_PASSWORD_ENV_VAR).is_err();
250 let target_uses_encrypted = target_storage.backend.is_sqlite()
251 && (options.token_password.is_some()
252 || std::env::var(VAULT_PASSWORD_ENV_VAR).is_ok());
253
254 source_uses_keyring && target_uses_encrypted
255 };
256
257 if token_backend_changing {
258 tracing::info!(
259 "[Migration Planning] Token backend change detected: keyring → encrypted storage"
260 );
261 }
262
263 let needs_data_migration = backend_changed || data_dir_changed || token_backend_changing;
264
265 if target_storage.backend == ConfigStorageBackend::Postgres {
266 if target_storage.data_dir.starts_with("postgresql://")
267 || target_storage.data_dir.starts_with("postgres://")
268 {
269 tracing::warn!(
270 "Target data_dir contains PostgreSQL connection string: {}. \
271 Resetting to empty (will use default) - connection string should be in postgres.connection_string field.",
272 target_storage.data_dir
273 );
274 target_storage.data_dir = String::new(); } else if target_storage.data_dir.is_empty() {
276 tracing::debug!("Target uses PostgreSQL - data_dir is empty (will use default for cache/local files)");
277 }
278 }
279
280 let mut steps = Vec::new();
281
282 steps.push(MigrationStep::ValidateTarget);
283
284 if needs_data_migration {
285 steps.push(MigrationStep::MigrateConfigs);
286 }
287
288 if needs_data_migration && options.migrate_tokens {
289 steps.push(MigrationStep::MigrateTokens);
290 }
291
292 if needs_data_migration && options.migrate_cache {
293 steps.push(MigrationStep::MigrateCache);
294 }
295
296 if !steps.is_empty() {
297 steps.push(MigrationStep::VerifyMigration);
298 steps.push(MigrationStep::UpdateConfig);
299 }
300
301 Ok(MigrationPlan {
302 from: self.source_config.clone(),
303 to: target_config,
304 steps,
305 migrate_tokens: needs_data_migration && options.migrate_tokens,
306 migrate_configs: needs_data_migration,
307 migrate_cache: needs_data_migration && options.migrate_cache,
308 backend_changed,
309 data_dir_changed,
310 created_at: Utc::now(),
311 })
312 }
313
314 pub async fn validate_target_config(
315 &self, config: &StorageConfig,
316 ) -> DomainResult<ValidationReport> {
317 use crate::infrastructure::config::StorageBackend as ConfigStorageBackend;
318
319 let mut report = ValidationReport {
320 errors: Vec::new(),
321 warnings: Vec::new(),
322 recommendations: Vec::new(),
323 };
324
325 if config.backend == ConfigStorageBackend::Postgres {
326 if config.postgres.connection_string.is_empty() {
327 report.errors.push(
328 "PostgreSQL connection string required when using postgres backend".into(),
329 );
330 }
331
332 let has_settings_password = config.vault_password.is_some();
333 let has_env_password = std::env::var(VAULT_PASSWORD_ENV_VAR).is_ok();
334
335 if !has_settings_password && !has_env_password {
336 report.warnings.push(format!(
337 "Vault password not provided. Set {} environment variable or provide password during setup.",
338 VAULT_PASSWORD_ENV_VAR
339 ));
340 }
341 }
342
343 Ok(report)
344 }
345
346 pub async fn execute_migration(
347 &self, plan: MigrationPlan, options: MigrationOptions, is_desktop: bool,
348 ) -> MigrationResult {
349 let start_time = std::time::Instant::now();
350 let mut result = MigrationResult {
351 success: false,
352 steps_completed: Vec::new(),
353 errors: Vec::new(),
354 duration_ms: 0,
355 stats: MigrationStats::default(),
356 provider_id_mapping: std::collections::HashMap::new(),
357 backups: None,
358 };
359
360 if options.dry_run {
361 result.success = true;
362 result.duration_ms = start_time.elapsed().as_millis() as u64;
363 return result;
364 }
365
366 let backup_manager = BackupManager::new(plan.from.data_dir());
367 let backups = match backup_manager
368 .create_backups(&plan.from, &plan.to, Some(&*self.source_config_backend))
369 .await
370 {
371 Ok(backups) => backups,
372 Err(e) => {
373 result.errors.push(format!(
374 "Failed to create backups: {}\n\n\
375 WARNING: Migration aborted - backup creation is required for safe migration.\n\
376 INFO: Your data is safe - no modifications were performed.\n\n\
377 Please ensure:\n\
378 1. Sufficient disk space (need ~2x source database size)\n\
379 2. Write permissions to data directory\n\
380 3. Source database is not locked by another process",
381 e
382 ));
383 result.duration_ms = start_time.elapsed().as_millis() as u64;
384 return result;
385 }
386 };
387 result.backups = Some(backups.clone());
388
389 let target_manager = if let Some(token_store) = &self.target_token_store {
390 match StorageManager::with_token_store(plan.to.clone(), token_store.clone(), is_desktop)
391 .await
392 {
393 Ok(m) => m,
394 Err(e) => {
395 result.errors.push(format!(
396 "Failed to create target manager with token store: {}\n\n\
397 INFO: Migration aborted before any changes were made.\n\
398 INFO: Your data is safe - no modifications were performed.",
399 e
400 ));
401 backup_manager.cleanup_backups(&backups, false).await.ok();
402 result.duration_ms = start_time.elapsed().as_millis() as u64;
403 return result;
404 }
405 }
406 } else {
407 match StorageManager::from_config(plan.to.clone(), false).await {
408 Ok(m) => m,
409 Err(e) => {
410 result.errors.push(format!(
411 "Failed to create target manager: {}\n\n\
412 INFO: Migration aborted before any changes were made.\n\
413 INFO: Your data is safe - no modifications were performed.",
414 e
415 ));
416 backup_manager.cleanup_backups(&backups, false).await.ok();
417 result.duration_ms = start_time.elapsed().as_millis() as u64;
418 return result;
419 }
420 }
421 };
422
423 for (idx, step) in plan.steps.iter().enumerate() {
424 self.emit_progress(*step, idx, plan.steps.len()).await;
425
426 let step_result = self
427 .execute_step(*step, &plan, &options, &target_manager, &mut result)
428 .await;
429
430 match step_result {
431 Ok(()) => {
432 result.steps_completed.push(*step);
433 }
434 Err(e) => {
435 let step_name = format!("{:?}", step);
436
437 tracing::error!(
438 "Migration failed at step {} - restoring from backups",
439 step_name
440 );
441
442 let recovery_instructions = match backup_manager.restore_backups(&backups).await
443 {
444 Ok(()) => {
445 tracing::info!("Successfully restored from backups");
446 format!(
447 "✓ Your data has been restored from backups.\n\
448 ✓ Config file: {}\n\
449 ✓ Database: {}\n\n\
450 The migration failed but your original data is intact.\n\
451 Please review the error below and try again after fixing the issue:\n\n\
452 Error: {}",
453 backups.source_config.display(),
454 backups.source_db.as_ref().map(|p| p.display().to_string()).unwrap_or_else(|| "N/A".to_string()),
455 e
456 )
457 }
458 Err(restore_err) => {
459 format!(
460 "CRITICAL ERROR: Migration failed AND backup restoration failed!\n\n\
461 Original error: {}\n\
462 Restore error: {}\n\n\
463 MANUAL RECOVERY REQUIRED:\n\
464 1. Restore config file from: {}\n\
465 2. Restore database from: {}\n\
466 3. Review logs for details\n\
467 4. Contact support if needed",
468 e,
469 restore_err,
470 backups.source_config.display(),
471 backups.source_db.as_ref().map(|p| p.display().to_string()).unwrap_or_else(|| "N/A".to_string())
472 )
473 }
474 };
475
476 result.errors.push(format!(
477 "Migration failed at step '{}':\n{}\n\n{}",
478 step_name, e, recovery_instructions
479 ));
480
481 self.rollback(&result).await;
482 result.duration_ms = start_time.elapsed().as_millis() as u64;
483 return result;
484 }
485 }
486 }
487
488 result.success = true;
489 result.duration_ms = start_time.elapsed().as_millis() as u64;
490
491 if let Err(e) = backup_manager
492 .cleanup_backups(&backups, options.keep_backups)
493 .await
494 {
495 tracing::warn!("Failed to cleanup backups: {}", e);
496 }
497
498 result
499 }
500
501 async fn execute_step(
502 &self, step: MigrationStep, plan: &MigrationPlan, options: &MigrationOptions,
503 target_manager: &StorageManager, result: &mut MigrationResult,
504 ) -> DomainResult<()> {
505 match step {
506 MigrationStep::ValidateTarget => {
507 if !target_manager.cache_backend().is_available().await {
508 return Err(DomainError::NetworkError(
509 "Target cache backend not available".into(),
510 ));
511 }
512
513 target_manager.config_backend().list_providers().await?;
514 }
515
516 MigrationStep::MigrateTokens => {
517 tracing::info!("[Migration] Starting token migration...");
518 let password = get_vault_password(options)?;
519 tracing::info!(
520 "[Migration] Vault password retrieved: {} chars",
521 password.len()
522 );
523
524 tracing::info!("[Migration] Exporting tokens from source token store...");
525 let encrypted_blob = self.source_token_store.export_encrypted(&password).await?;
526 tracing::info!(
527 "[Migration] Exported encrypted blob: {} bytes",
528 encrypted_blob.len()
529 );
530
531 if !result.provider_id_mapping.is_empty() {
532 tracing::info!(
533 "[Migration] Using provider ID remapping path ({} mappings)",
534 result.provider_id_mapping.len()
535 );
536
537 let source_tokens = self.source_token_store.get_all_tokens().await?;
538 tracing::info!(
539 "[Migration] Retrieved {} tokens from source",
540 source_tokens.len()
541 );
542
543 let mut migrated_count = 0;
544 for (old_id, token) in source_tokens {
545 if let Some(&new_id) = result.provider_id_mapping.get(&old_id) {
546 tracing::debug!(
547 "[Migration] Storing token for provider {} → {} (token length: {})",
548 old_id,
549 new_id,
550 token.len()
551 );
552 target_manager
553 .token_store()
554 .await
555 .store_token(new_id, &token)
556 .await?;
557 migrated_count += 1;
558 tracing::info!(
559 "[Migration] Successfully stored token for provider {} (remapped to {})",
560 old_id,
561 new_id
562 );
563 } else {
564 let source_providers =
565 self.source_config_backend.list_providers().await?;
566 return Err(DomainError::DataConsistency(format!(
567 "Cannot migrate token for provider ID {} - provider not found in remapping.\n\n\
568 This indicates data corruption or inconsistency.\n\n\
569 Source provider IDs: {:?}\n\
570 Remapping table: {:?}\n\n\
571 This token would be lost if migration continued.\n\
572 Please investigate why this provider's configuration wasn't migrated.",
573 old_id,
574 source_providers.iter().filter_map(|p| p.id).collect::<Vec<_>>(),
575 result.provider_id_mapping
576 )));
577 }
578 }
579 tracing::info!(
580 "[Migration] Migrated {} tokens via ID remapping",
581 migrated_count
582 );
583 } else {
584 tracing::info!("[Migration] Using direct import path (no ID remapping)");
585 tracing::info!(
586 "[Migration] Importing encrypted blob ({} bytes) to target token store...",
587 encrypted_blob.len()
588 );
589 target_manager
590 .token_store()
591 .await
592 .import_encrypted(&encrypted_blob, &password)
593 .await?;
594 tracing::info!("[Migration] Direct import completed");
595 }
596
597 let count: usize = target_manager
598 .token_store()
599 .await
600 .get_all_tokens()
601 .await?
602 .len();
603 tracing::info!(
604 "[Migration] Verification: {} tokens now in target database",
605 count
606 );
607 result.stats.tokens_migrated = count;
608 }
609
610 MigrationStep::MigrateConfigs => {
611 tracing::info!(
612 "[Migration] Exporting providers from source database: {:?}",
613 self.source_config.db_path()
614 );
615 let export = self.source_config_backend.export_all().await?;
616 result.stats.providers_migrated = export.providers.len();
617 result.stats.permissions_migrated = export.permissions.len();
618
619 tracing::info!(
620 "[Migration] Importing {} providers to target database: {:?}",
621 export.providers.len(),
622 plan.to.db_path()
623 );
624 target_manager.config_backend().import_all(&export).await?;
625
626 let source_providers = self.source_config_backend.list_providers().await?;
627 let target_providers: Vec<ProviderConfig> =
628 target_manager.config_backend().list_providers().await?;
629
630 for source_provider in &source_providers {
631 if let Some(source_id) = source_provider.id {
632 if let Some(target_provider) = target_providers
633 .iter()
634 .find(|p| p.name == source_provider.name)
635 {
636 if let Some(target_id) = target_provider.id {
637 result.provider_id_mapping.insert(source_id, target_id);
638 }
639 }
640 }
641 }
642
643 if options.clean_target {
644 let source_names: std::collections::HashSet<String> =
645 source_providers.iter().map(|p| p.name.clone()).collect();
646
647 let orphaned: Vec<_> = target_providers
648 .iter()
649 .filter(|p| !source_names.contains(&p.name))
650 .collect();
651
652 for orphan in &orphaned {
653 if let Some(id) = orphan.id {
654 tracing::info!(
655 "[Migration] Removing orphaned provider: {} (id={})",
656 orphan.name,
657 id
658 );
659 target_manager.config_backend().delete_provider(id).await?;
660 let _ = target_manager.token_store().await.delete_token(id).await;
661 result.stats.tokens_cleaned += 1;
662 }
663 }
664
665 if !orphaned.is_empty() {
666 result.stats.providers_cleaned = orphaned.len();
667 tracing::info!(
668 "[Migration] Cleaned {} orphaned providers from target",
669 orphaned.len()
670 );
671 }
672 }
673 }
674
675 MigrationStep::MigrateCache => {
676 let objects = self.source_cache_backend.list(None).await?;
677 tracing::info!(
678 "[Migration] Migrating {} cache entries from {:?} to {:?}",
679 objects.len(),
680 self.source_config.data_dir().join("cache"),
681 plan.to.data_dir().join("cache")
682 );
683 for obj in objects {
684 let data = self.source_cache_backend.get(&obj.key).await?;
685 target_manager
686 .cache_backend()
687 .put(&obj.key, &data, obj.content_type.as_deref())
688 .await?;
689 result.stats.cache_entries_migrated += 1;
690 }
691 }
692
693 MigrationStep::VerifyMigration => {
694 let source_providers = self.source_config_backend.list_providers().await?;
695
696 if plan.migrate_configs && !source_providers.is_empty() {
697 let target_providers: Vec<ProviderConfig> =
698 target_manager.config_backend().list_providers().await?;
699
700 for source_provider in &source_providers {
701 let found = target_providers
702 .iter()
703 .any(|t| t.name == source_provider.name);
704 if !found {
705 return Err(DomainError::DatabaseError(format!(
706 "Provider '{}' from source was not found in target after migration",
707 source_provider.name
708 )));
709 }
710 }
711
712 if options.clean_target {
713 if source_providers.len() != target_providers.len() {
714 return Err(DomainError::DatabaseError(format!(
715 "Provider count mismatch after cleanup: source={}, target={}",
716 source_providers.len(),
717 target_providers.len()
718 )));
719 }
720 tracing::info!(
721 "[Migration] Verified exact match: {} providers",
722 source_providers.len()
723 );
724 } else if target_providers.len() > source_providers.len() {
725 tracing::info!(
726 "[Migration] Merge complete: {} migrated, {} pre-existing kept (total: {})",
727 source_providers.len(),
728 target_providers.len() - source_providers.len(),
729 target_providers.len()
730 );
731 } else {
732 tracing::info!(
733 "[Migration] Verified {} providers migrated successfully",
734 source_providers.len()
735 );
736 }
737 } else if plan.migrate_configs {
738 tracing::info!("Skipping provider verification: source database is empty");
739 }
740
741 if plan.migrate_tokens {
742 let source_tokens = self.source_token_store.get_all_tokens().await?;
743
744 if !source_tokens.is_empty() {
745 let target_tokens: HashMap<i64, String> =
746 target_manager.token_store().await.get_all_tokens().await?;
747
748 if options.clean_target {
749 if source_tokens.len() != target_tokens.len() {
750 return Err(DomainError::DatabaseError(format!(
751 "Token count mismatch after cleanup: source={}, target={}",
752 source_tokens.len(),
753 target_tokens.len()
754 )));
755 }
756 tracing::info!(
757 "[Migration] Verified exact match: {} tokens",
758 source_tokens.len()
759 );
760 } else {
761 if target_tokens.len() < source_tokens.len() {
762 return Err(DomainError::DatabaseError(format!(
763 "Token count too low after migration: source={}, target={}",
764 source_tokens.len(),
765 target_tokens.len()
766 )));
767 }
768 tracing::info!(
769 "[Migration] Token migration complete: {} migrated (target has {} total)",
770 source_tokens.len(),
771 target_tokens.len()
772 );
773 }
774 } else {
775 tracing::info!("Skipping token verification: source has no tokens");
776 }
777 }
778 }
779
780 MigrationStep::UpdateConfig => {
781 use crate::infrastructure::config::ConfigLoader;
782
783 let config_path = ConfigLoader::discover_config_path();
784
785 tracing::info!(
786 "Saving migrated config to: {} (data_dir: {:?}, backend: {})",
787 config_path.display(),
788 plan.to.data_dir(),
789 plan.to.storage.backend
790 );
791
792 ConfigLoader::save(&plan.to, &config_path).map_err(|e| {
793 DomainError::DatabaseError(format!("Failed to save config: {}", e))
794 })?;
795 }
796 }
797
798 Ok(())
799 }
800
801 async fn emit_progress(&self, step: MigrationStep, step_index: usize, total_steps: usize) {
802 if let Some(ref event_bus) = self.event_bus {
803 event_bus
804 .emit(crate::event::CoreEvent::MigrationProgress {
805 step: format!("{:?}", step),
806 step_index,
807 total_steps,
808 message: step.description().to_string(),
809 })
810 .await;
811 }
812 }
813
814 async fn rollback(&self, _result: &MigrationResult) {
815 tracing::warn!("Migration failed. Manual intervention may be required.");
816 }
817}
818
819#[cfg(test)]
820mod tests {
821 use tempfile::TempDir;
822
823 use super::*;
824 use crate::{
825 infrastructure::token_store::MemoryTokenStore,
826 test_env::EnvGuard,
827 };
828
829 async fn create_test_manager(temp_dir: &TempDir) -> StorageManager {
830 let mut config = PipedashConfig::default();
831 config.storage.data_dir = temp_dir.path().to_string_lossy().to_string();
832
833 let token_store: Arc<dyn TokenStore> = Arc::new(MemoryTokenStore::new());
834 StorageManager::with_token_store(config, token_store, true)
835 .await
836 .unwrap()
837 }
838
839 #[tokio::test]
840 async fn test_plan_migration_no_changes() {
841 let temp_dir = TempDir::new().unwrap();
842 let manager = create_test_manager(&temp_dir).await;
843
844 let orchestrator = MigrationOrchestrator::from_manager(&manager, None, None).await;
845
846 let target_config = manager.config().clone();
847 let plan = orchestrator
848 .plan_migration(target_config, &MigrationOptions::default())
849 .unwrap();
850
851 assert!(!plan.steps.contains(&MigrationStep::MigrateConfigs));
852 assert!(!plan.steps.contains(&MigrationStep::MigrateTokens));
853 }
854
855 #[tokio::test]
856 async fn test_migration_dry_run() {
857 let temp_dir = TempDir::new().unwrap();
858 let manager = create_test_manager(&temp_dir).await;
859
860 let orchestrator = MigrationOrchestrator::from_manager(&manager, None, None).await;
861
862 let target_config = manager.config().clone();
863 let plan = MigrationPlan {
864 from: manager.config().clone(),
865 to: target_config,
866 steps: vec![MigrationStep::ValidateTarget],
867 migrate_tokens: false,
868 migrate_configs: false,
869 migrate_cache: false,
870 backend_changed: false,
871 data_dir_changed: false,
872 created_at: Utc::now(),
873 };
874
875 let result = orchestrator
876 .execute_migration(
877 plan,
878 MigrationOptions {
879 dry_run: true,
880 ..Default::default()
881 },
882 true, )
884 .await;
885
886 assert!(result.success);
887 }
888
889 #[test]
890 fn test_migration_step_descriptions() {
891 assert!(!MigrationStep::ValidateTarget.description().is_empty());
892 assert!(!MigrationStep::MigrateTokens.description().is_empty());
893 assert!(!MigrationStep::MigrateConfigs.description().is_empty());
894 }
895
896 #[test]
897 fn test_migration_result_summary() {
898 let result = MigrationResult {
899 success: true,
900 steps_completed: vec![MigrationStep::MigrateConfigs],
901 errors: vec![],
902 duration_ms: 100,
903 stats: MigrationStats {
904 providers_migrated: 5,
905 tokens_migrated: 3,
906 cache_entries_migrated: 10,
907 permissions_migrated: 2,
908 providers_cleaned: 0,
909 tokens_cleaned: 0,
910 },
911 provider_id_mapping: std::collections::HashMap::new(),
912 backups: None,
913 };
914
915 let summary = result.summary();
916 assert!(summary.contains("5 providers"));
917 assert!(summary.contains("10 cache entries"));
918 }
919
920 #[tokio::test]
921 async fn migrate_tokens_after_a_provider_was_deleted() {
922 let source_dir = TempDir::new().unwrap();
923 let target_dir = TempDir::new().unwrap();
924 let config_path = source_dir.path().join("config.toml");
925 std::fs::write(&config_path, "[storage]\nbackend = \"sqlite\"\n").unwrap();
926 let _env = EnvGuard::set(&[("PIPEDASH_CONFIG_PATH", config_path.to_str())]);
927
928 let mut source = PipedashConfig::default();
929 source.storage.data_dir = source_dir.path().to_string_lossy().to_string();
930 source.storage.vault_password = Some("test-password".to_string());
931 let mut target = source.clone();
932 target.storage.data_dir = target_dir.path().to_string_lossy().to_string();
933
934 let manager = StorageManager::from_config(source, false).await.unwrap();
935 let backend = manager.config_backend();
936 let tokens = manager.token_store().await;
937 let mut ids = Vec::new();
938 for name in ["a", "b", "c"] {
939 let provider = ProviderConfig {
940 id: None,
941 name: name.to_string(),
942 provider_type: "github".to_string(),
943 token: String::new(),
944 config: HashMap::new(),
945 refresh_interval: 30,
946 version: None,
947 };
948 ids.push(backend.create_provider(&provider).await.unwrap());
949 }
950 backend.delete_provider(ids[1]).await.unwrap();
951 tokens.store_token(ids[0], "tok-a").await.unwrap();
952 tokens.store_token(ids[2], "tok-c").await.unwrap();
953
954 let orchestrator = MigrationOrchestrator::from_manager(&manager, None, None).await;
955 let options = MigrationOptions {
956 migrate_tokens: true,
957 token_password: Some("test-password".to_string()),
958 ..Default::default()
959 };
960 let plan = orchestrator
961 .plan_migration(target.clone(), &options)
962 .unwrap();
963 let result = orchestrator.execute_migration(plan, options, false).await;
964 assert!(result.success, "{:?}", result.errors);
965
966 let target_manager = StorageManager::from_config(target, false).await.unwrap();
967 let mut migrated = HashMap::new();
968 for provider in target_manager
969 .config_backend()
970 .list_providers()
971 .await
972 .unwrap()
973 {
974 let token = target_manager
975 .token_store()
976 .await
977 .get_token(provider.id.unwrap())
978 .await
979 .unwrap();
980 migrated.insert(provider.name, token);
981 }
982 assert_eq!(
983 migrated,
984 HashMap::from([
985 ("a".to_string(), "tok-a".to_string()),
986 ("c".to_string(), "tok-c".to_string()),
987 ])
988 );
989 }
990}