Skip to main content

ralph/migration/
mod.rs

1//! Purpose: Provide the public migration API surface and top-level
2//! orchestration.
3//!
4//! Responsibilities:
5//! - Declare migration companion modules.
6//! - Re-export migration data models and context types.
7//! - Check, apply, and list migrations without owning leaf migration logic.
8//!
9//! Scope:
10//! - Top-level orchestration only; type definitions live in `types.rs`, history
11//!   persistence lives in `history.rs`, and leaf migration behavior lives in
12//!   `config_migrations/` and `file_migrations/`.
13//!
14//! Usage:
15//! - Import migration helpers through `crate::migration`.
16//! - Internal modules may use the re-exported types or sibling companion
17//!   modules.
18//!
19//! Invariants/Assumptions:
20//! - Re-exports preserve existing caller imports.
21//! - Migrations remain idempotent and registry-driven.
22//! - Migration history is saved immediately after each successful apply.
23
24use anyhow::{Context, Result};
25
26pub mod config_migrations;
27pub mod file_migrations;
28pub mod history;
29pub mod registry;
30mod types;
31
32#[cfg(test)]
33mod tests;
34
35pub use types::{
36    Migration, MigrationCheckResult, MigrationContext, MigrationStatus, MigrationType,
37};
38
39/// Check for pending migrations without applying them.
40pub fn check_migrations(ctx: &MigrationContext) -> Result<MigrationCheckResult> {
41    let pending: Vec<&'static Migration> = registry::MIGRATIONS
42        .iter()
43        .filter(|migration| {
44            !ctx.is_migration_applied(migration.id) && is_migration_applicable(ctx, migration)
45        })
46        .collect();
47
48    if pending.is_empty() {
49        Ok(MigrationCheckResult::Current)
50    } else {
51        Ok(MigrationCheckResult::Pending(pending))
52    }
53}
54
55/// Check if a specific migration is applicable in the current context.
56fn is_migration_applicable(ctx: &MigrationContext, migration: &Migration) -> bool {
57    match &migration.migration_type {
58        MigrationType::ConfigKeyRename { old_key, .. } => {
59            config_migrations::config_has_key(ctx, old_key)
60        }
61        MigrationType::ConfigKeyRemove { key } => config_migrations::config_has_key(ctx, key),
62        MigrationType::ConfigCiGateRewrite => {
63            config_migrations::config_has_key(ctx, "agent.ci_gate_command")
64                || config_migrations::config_has_key(ctx, "agent.ci_gate_enabled")
65        }
66        MigrationType::ConfigLegacyContractUpgrade => {
67            config_migrations::config_needs_legacy_contract_upgrade(ctx)
68        }
69        MigrationType::FileRename { old_path, new_path } => {
70            if matches!(
71                migration.id,
72                "file_cleanup_legacy_queue_json_after_jsonc_2026_02"
73                    | "file_cleanup_legacy_done_json_after_jsonc_2026_02"
74                    | "file_cleanup_legacy_config_json_after_jsonc_2026_02"
75            ) {
76                return ctx.file_exists(old_path) && ctx.file_exists(new_path);
77            }
78            match (*old_path, *new_path) {
79                (".ralph/queue.json", ".ralph/queue.jsonc")
80                | (".ralph/done.json", ".ralph/done.jsonc")
81                | (".ralph/config.json", ".ralph/config.jsonc") => ctx.file_exists(old_path),
82                _ => ctx.file_exists(old_path) && !ctx.file_exists(new_path),
83            }
84        }
85        MigrationType::ReadmeUpdate { from_version, .. } => {
86            if let Ok(result) =
87                crate::commands::init::readme::check_readme_current_from_root(&ctx.repo_root)
88            {
89                match result {
90                    crate::commands::init::readme::ReadmeCheckResult::Current(version) => {
91                        version < *from_version
92                    }
93                    crate::commands::init::readme::ReadmeCheckResult::Outdated {
94                        current_version,
95                        ..
96                    } => current_version < *from_version,
97                    _ => false,
98                }
99            } else {
100                false
101            }
102        }
103    }
104}
105
106/// Apply a single migration.
107pub fn apply_migration(ctx: &mut MigrationContext, migration: &Migration) -> Result<()> {
108    if ctx.is_migration_applied(migration.id) {
109        log::debug!("Migration {} already applied, skipping", migration.id);
110        return Ok(());
111    }
112
113    log::info!(
114        "Applying migration: {} - {}",
115        migration.id,
116        migration.description
117    );
118
119    match &migration.migration_type {
120        MigrationType::ConfigKeyRename { old_key, new_key } => {
121            config_migrations::apply_key_rename(ctx, old_key, new_key)
122                .with_context(|| format!("apply config key rename for {}", migration.id))?;
123        }
124        MigrationType::ConfigKeyRemove { key } => {
125            config_migrations::apply_key_remove(ctx, key)
126                .with_context(|| format!("apply config key removal for {}", migration.id))?;
127        }
128        MigrationType::ConfigCiGateRewrite => {
129            config_migrations::apply_ci_gate_rewrite(ctx)
130                .with_context(|| format!("apply CI gate rewrite for {}", migration.id))?;
131        }
132        MigrationType::ConfigLegacyContractUpgrade => {
133            config_migrations::apply_legacy_contract_upgrade(ctx)
134                .with_context(|| format!("apply legacy config upgrade for {}", migration.id))?;
135        }
136        MigrationType::FileRename { old_path, new_path } => match (*old_path, *new_path) {
137            (".ralph/queue.json", ".ralph/queue.jsonc") => {
138                file_migrations::migrate_queue_json_to_jsonc(ctx)
139                    .with_context(|| format!("apply file rename for {}", migration.id))?;
140            }
141            (".ralph/done.json", ".ralph/done.jsonc") => {
142                file_migrations::migrate_done_json_to_jsonc(ctx)
143                    .with_context(|| format!("apply file rename for {}", migration.id))?;
144            }
145            (".ralph/config.json", ".ralph/config.jsonc") => {
146                file_migrations::migrate_config_json_to_jsonc(ctx)
147                    .with_context(|| format!("apply file rename for {}", migration.id))?;
148            }
149            _ => {
150                file_migrations::apply_file_rename(ctx, old_path, new_path)
151                    .with_context(|| format!("apply file rename for {}", migration.id))?;
152            }
153        },
154        MigrationType::ReadmeUpdate { .. } => {
155            apply_readme_update(ctx)
156                .with_context(|| format!("apply README update for {}", migration.id))?;
157        }
158    }
159
160    ctx.migration_history
161        .applied_migrations
162        .push(history::AppliedMigration {
163            id: migration.id.to_string(),
164            applied_at: chrono::Utc::now(),
165            migration_type: format!("{:?}", migration.migration_type),
166        });
167
168    history::save_migration_history(&ctx.repo_root, &ctx.migration_history)
169        .with_context(|| format!("save migration history after {}", migration.id))?;
170
171    log::info!("Successfully applied migration: {}", migration.id);
172    Ok(())
173}
174
175/// Apply all pending migrations.
176pub fn apply_all_migrations(ctx: &mut MigrationContext) -> Result<Vec<&'static str>> {
177    let pending = match check_migrations(ctx)? {
178        MigrationCheckResult::Current => return Ok(Vec::new()),
179        MigrationCheckResult::Pending(migrations) => migrations,
180    };
181
182    let mut applied = Vec::new();
183    for migration in pending {
184        apply_migration(ctx, migration)
185            .with_context(|| format!("apply migration {}", migration.id))?;
186        applied.push(migration.id);
187    }
188
189    Ok(applied)
190}
191
192/// Apply README update migration.
193fn apply_readme_update(ctx: &MigrationContext) -> Result<()> {
194    let readme_path = ctx.repo_root.join(".ralph/README.md");
195    if !readme_path.exists() {
196        anyhow::bail!("README.md does not exist at {}", readme_path.display());
197    }
198
199    let (status, _) = crate::commands::init::readme::write_readme(&readme_path, false, true)
200        .context("write updated README")?;
201
202    match status {
203        crate::commands::init::FileInitStatus::Updated => Ok(()),
204        crate::commands::init::FileInitStatus::Created => Ok(()),
205        crate::commands::init::FileInitStatus::Valid => Ok(()),
206    }
207}
208
209/// List all migrations with their status.
210pub fn list_migrations(ctx: &MigrationContext) -> Vec<MigrationStatus<'_>> {
211    registry::MIGRATIONS
212        .iter()
213        .map(|migration| {
214            let applied = ctx.is_migration_applied(migration.id);
215            let applicable = is_migration_applicable(ctx, migration);
216            MigrationStatus {
217                migration,
218                applied,
219                applicable,
220            }
221        })
222        .collect()
223}