1use crate::config::Resolved;
20use anyhow::{Context, Result};
21use std::path::PathBuf;
22
23pub mod config_migrations;
24pub mod file_migrations;
25pub mod history;
26pub mod registry;
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum MigrationCheckResult {
31 Current,
33 Pending(Vec<&'static Migration>),
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Migration {
40 pub id: &'static str,
42 pub description: &'static str,
44 pub migration_type: MigrationType,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum MigrationType {
51 ConfigKeyRename {
53 old_key: &'static str,
55 new_key: &'static str,
57 },
58 ConfigKeyRemove {
60 key: &'static str,
62 },
63 ConfigCiGateRewrite,
65 FileRename {
67 old_path: &'static str,
69 new_path: &'static str,
71 },
72 ReadmeUpdate {
74 from_version: u32,
76 to_version: u32,
78 },
79}
80
81#[derive(Debug, Clone)]
83pub struct MigrationContext {
84 pub repo_root: PathBuf,
86 pub project_config_path: PathBuf,
88 pub global_config_path: Option<PathBuf>,
90 pub resolved_config: crate::contracts::Config,
92 pub migration_history: history::MigrationHistory,
94}
95
96impl MigrationContext {
97 pub fn from_resolved(resolved: &Resolved) -> Result<Self> {
99 let migration_history = history::load_migration_history(&resolved.repo_root)
100 .context("load migration history")?;
101
102 Ok(Self {
103 repo_root: resolved.repo_root.clone(),
104 project_config_path: resolved
105 .project_config_path
106 .clone()
107 .unwrap_or_else(|| resolved.repo_root.join(".ralph/config.jsonc")),
108 global_config_path: resolved.global_config_path.clone(),
109 resolved_config: resolved.config.clone(),
110 migration_history,
111 })
112 }
113
114 pub fn is_migration_applied(&self, migration_id: &str) -> bool {
116 self.migration_history
117 .applied_migrations
118 .iter()
119 .any(|m| m.id == migration_id)
120 }
121
122 pub fn file_exists(&self, path: &str) -> bool {
124 self.repo_root.join(path).exists()
125 }
126
127 pub fn resolve_path(&self, path: &str) -> PathBuf {
129 self.repo_root.join(path)
130 }
131}
132
133pub fn check_migrations(ctx: &MigrationContext) -> Result<MigrationCheckResult> {
135 let pending: Vec<&'static Migration> = registry::MIGRATIONS
136 .iter()
137 .filter(|m| !ctx.is_migration_applied(m.id) && is_migration_applicable(ctx, m))
138 .collect();
139
140 if pending.is_empty() {
141 Ok(MigrationCheckResult::Current)
142 } else {
143 Ok(MigrationCheckResult::Pending(pending))
144 }
145}
146
147fn is_migration_applicable(ctx: &MigrationContext, migration: &Migration) -> bool {
149 match &migration.migration_type {
150 MigrationType::ConfigKeyRename { old_key, .. } => {
151 config_migrations::config_has_key(ctx, old_key)
152 }
153 MigrationType::ConfigKeyRemove { key } => config_migrations::config_has_key(ctx, key),
154 MigrationType::ConfigCiGateRewrite => {
155 config_migrations::config_has_key(ctx, "agent.ci_gate_command")
156 || config_migrations::config_has_key(ctx, "agent.ci_gate_enabled")
157 }
158 MigrationType::FileRename { old_path, new_path } => {
159 if matches!(
160 migration.id,
161 "file_cleanup_legacy_queue_json_after_jsonc_2026_02"
162 | "file_cleanup_legacy_done_json_after_jsonc_2026_02"
163 | "file_cleanup_legacy_config_json_after_jsonc_2026_02"
164 ) {
165 return ctx.file_exists(old_path) && ctx.file_exists(new_path);
166 }
167 match (*old_path, *new_path) {
168 (".ralph/queue.json", ".ralph/queue.jsonc")
169 | (".ralph/done.json", ".ralph/done.jsonc")
170 | (".ralph/config.json", ".ralph/config.jsonc") => ctx.file_exists(old_path),
171 _ => ctx.file_exists(old_path) && !ctx.file_exists(new_path),
172 }
173 }
174 MigrationType::ReadmeUpdate { from_version, .. } => {
175 if let Ok(result) =
178 crate::commands::init::readme::check_readme_current_from_root(&ctx.repo_root)
179 {
180 match result {
181 crate::commands::init::readme::ReadmeCheckResult::Current(v) => {
182 v < *from_version
183 }
184 crate::commands::init::readme::ReadmeCheckResult::Outdated {
185 current_version,
186 ..
187 } => current_version < *from_version,
188 _ => false,
189 }
190 } else {
191 false
192 }
193 }
194 }
195}
196
197pub fn apply_migration(ctx: &mut MigrationContext, migration: &Migration) -> Result<()> {
199 if ctx.is_migration_applied(migration.id) {
200 log::debug!("Migration {} already applied, skipping", migration.id);
201 return Ok(());
202 }
203
204 log::info!(
205 "Applying migration: {} - {}",
206 migration.id,
207 migration.description
208 );
209
210 match &migration.migration_type {
211 MigrationType::ConfigKeyRename { old_key, new_key } => {
212 config_migrations::apply_key_rename(ctx, old_key, new_key)
213 .with_context(|| format!("apply config key rename for {}", migration.id))?;
214 }
215 MigrationType::ConfigKeyRemove { key } => {
216 config_migrations::apply_key_remove(ctx, key)
217 .with_context(|| format!("apply config key removal for {}", migration.id))?;
218 }
219 MigrationType::ConfigCiGateRewrite => {
220 config_migrations::apply_ci_gate_rewrite(ctx)
221 .with_context(|| format!("apply CI gate rewrite for {}", migration.id))?;
222 }
223 MigrationType::FileRename { old_path, new_path } => match (*old_path, *new_path) {
224 (".ralph/queue.json", ".ralph/queue.jsonc") => {
225 file_migrations::migrate_queue_json_to_jsonc(ctx)
226 .with_context(|| format!("apply file rename for {}", migration.id))?;
227 }
228 (".ralph/done.json", ".ralph/done.jsonc") => {
229 file_migrations::migrate_done_json_to_jsonc(ctx)
230 .with_context(|| format!("apply file rename for {}", migration.id))?;
231 }
232 (".ralph/config.json", ".ralph/config.jsonc") => {
233 file_migrations::migrate_config_json_to_jsonc(ctx)
234 .with_context(|| format!("apply file rename for {}", migration.id))?;
235 }
236 _ => {
237 file_migrations::apply_file_rename(ctx, old_path, new_path)
238 .with_context(|| format!("apply file rename for {}", migration.id))?;
239 }
240 },
241 MigrationType::ReadmeUpdate { .. } => {
242 apply_readme_update(ctx)
243 .with_context(|| format!("apply README update for {}", migration.id))?;
244 }
245 }
246
247 ctx.migration_history
249 .applied_migrations
250 .push(history::AppliedMigration {
251 id: migration.id.to_string(),
252 applied_at: chrono::Utc::now(),
253 migration_type: format!("{:?}", migration.migration_type),
254 });
255
256 history::save_migration_history(&ctx.repo_root, &ctx.migration_history)
258 .with_context(|| format!("save migration history after {}", migration.id))?;
259
260 log::info!("Successfully applied migration: {}", migration.id);
261 Ok(())
262}
263
264pub fn apply_all_migrations(ctx: &mut MigrationContext) -> Result<Vec<&'static str>> {
266 let pending = match check_migrations(ctx)? {
267 MigrationCheckResult::Current => return Ok(Vec::new()),
268 MigrationCheckResult::Pending(migrations) => migrations,
269 };
270
271 let mut applied = Vec::new();
272 for migration in pending {
273 apply_migration(ctx, migration)
274 .with_context(|| format!("apply migration {}", migration.id))?;
275 applied.push(migration.id);
276 }
277
278 Ok(applied)
279}
280
281fn apply_readme_update(ctx: &MigrationContext) -> Result<()> {
283 let readme_path = ctx.repo_root.join(".ralph/README.md");
284 if !readme_path.exists() {
285 anyhow::bail!("README.md does not exist at {}", readme_path.display());
286 }
287
288 let (status, _) = crate::commands::init::readme::write_readme(&readme_path, false, true)
290 .context("write updated README")?;
291
292 match status {
293 crate::commands::init::FileInitStatus::Updated => Ok(()),
294 crate::commands::init::FileInitStatus::Created => {
295 Ok(())
297 }
298 crate::commands::init::FileInitStatus::Valid => {
299 Ok(())
301 }
302 }
303}
304
305pub fn list_migrations(ctx: &MigrationContext) -> Vec<MigrationStatus<'_>> {
307 registry::MIGRATIONS
308 .iter()
309 .map(|m| {
310 let applied = ctx.is_migration_applied(m.id);
311 let applicable = is_migration_applicable(ctx, m);
312 MigrationStatus {
313 migration: m,
314 applied,
315 applicable,
316 }
317 })
318 .collect()
319}
320
321#[derive(Debug, Clone)]
323pub struct MigrationStatus<'a> {
324 pub migration: &'a Migration,
326 pub applied: bool,
328 pub applicable: bool,
330}
331
332impl<'a> MigrationStatus<'a> {
333 pub fn status_text(&self) -> &'static str {
335 if self.applied {
336 "applied"
337 } else if self.applicable {
338 "pending"
339 } else {
340 "not applicable"
341 }
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use tempfile::TempDir;
349
350 fn create_test_context(dir: &TempDir) -> MigrationContext {
351 let repo_root = dir.path().to_path_buf();
352 let project_config_path = repo_root.join(".ralph/config.json");
353
354 MigrationContext {
355 repo_root,
356 project_config_path,
357 global_config_path: None,
358 resolved_config: crate::contracts::Config::default(),
359 migration_history: history::MigrationHistory::default(),
360 }
361 }
362
363 #[test]
364 fn migration_context_detects_applied_migration() {
365 let dir = TempDir::new().unwrap();
366 let mut ctx = create_test_context(&dir);
367
368 assert!(!ctx.is_migration_applied("test_migration"));
370
371 ctx.migration_history
373 .applied_migrations
374 .push(history::AppliedMigration {
375 id: "test_migration".to_string(),
376 applied_at: chrono::Utc::now(),
377 migration_type: "test".to_string(),
378 });
379
380 assert!(ctx.is_migration_applied("test_migration"));
382 }
383
384 #[test]
385 fn migration_context_file_exists_check() {
386 let dir = TempDir::new().unwrap();
387 let ctx = create_test_context(&dir);
388
389 std::fs::create_dir_all(dir.path().join(".ralph")).unwrap();
391 std::fs::write(dir.path().join(".ralph/queue.json"), "{}").unwrap();
392
393 assert!(ctx.file_exists(".ralph/queue.json"));
394 assert!(!ctx.file_exists(".ralph/done.json"));
395 }
396
397 #[test]
398 fn cleanup_migration_pending_when_legacy_json_remains_after_rename_migration() {
399 let dir = TempDir::new().unwrap();
400 let mut ctx = create_test_context(&dir);
401
402 std::fs::create_dir_all(dir.path().join(".ralph")).unwrap();
403 std::fs::write(dir.path().join(".ralph/queue.json"), "{}").unwrap();
404 std::fs::write(dir.path().join(".ralph/queue.jsonc"), "{}").unwrap();
405
406 ctx.migration_history
408 .applied_migrations
409 .push(history::AppliedMigration {
410 id: "file_rename_queue_json_to_jsonc_2026_02".to_string(),
411 applied_at: chrono::Utc::now(),
412 migration_type: "FileRename".to_string(),
413 });
414
415 let pending = match check_migrations(&ctx).expect("check migrations") {
416 MigrationCheckResult::Pending(pending) => pending,
417 MigrationCheckResult::Current => panic!("expected pending cleanup migration"),
418 };
419
420 let pending_ids: Vec<&str> = pending.iter().map(|m| m.id).collect();
421 assert!(
422 pending_ids.contains(&"file_cleanup_legacy_queue_json_after_jsonc_2026_02"),
423 "expected cleanup migration to be pending when legacy queue.json remains"
424 );
425 }
426}