Skip to main content

pipedash_core/infrastructure/
migration.rs

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(); // Use default data_dir
275            } 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, // is_desktop
883            )
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}