1use 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
39pub 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
55fn 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
106pub 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
175pub 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
192fn 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
209pub 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}