1use crate::config::Resolved;
20use anyhow::{Context, Result};
21use std::{env, 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 ConfigLegacyContractUpgrade,
67 FileRename {
69 old_path: &'static str,
71 new_path: &'static str,
73 },
74 ReadmeUpdate {
76 from_version: u32,
78 to_version: u32,
80 },
81}
82
83#[derive(Debug, Clone)]
85pub struct MigrationContext {
86 pub repo_root: PathBuf,
88 pub project_config_path: PathBuf,
90 pub global_config_path: Option<PathBuf>,
92 pub resolved_config: crate::contracts::Config,
94 pub migration_history: history::MigrationHistory,
96}
97
98impl MigrationContext {
99 pub fn from_resolved(resolved: &Resolved) -> Result<Self> {
101 Self::build(
102 resolved.repo_root.clone(),
103 resolved
104 .project_config_path
105 .clone()
106 .unwrap_or_else(|| resolved.repo_root.join(".ralph/config.jsonc")),
107 resolved.global_config_path.clone(),
108 resolved.config.clone(),
109 )
110 }
111
112 pub fn discover_from_cwd() -> Result<Self> {
115 let cwd = env::current_dir().context("resolve current working directory")?;
116 Self::discover_from_dir(&cwd)
117 }
118
119 pub fn discover_from_dir(start: &std::path::Path) -> Result<Self> {
122 let repo_root = crate::config::find_repo_root(start);
123 let project_config_path = crate::config::project_config_path(&repo_root);
124 let global_config_path = crate::config::global_config_path();
125
126 Self::build(
127 repo_root,
128 project_config_path,
129 global_config_path,
130 crate::contracts::Config::default(),
131 )
132 }
133
134 fn build(
135 repo_root: PathBuf,
136 project_config_path: PathBuf,
137 global_config_path: Option<PathBuf>,
138 resolved_config: crate::contracts::Config,
139 ) -> Result<Self> {
140 let migration_history =
141 history::load_migration_history(&repo_root).context("load migration history")?;
142
143 Ok(Self {
144 repo_root,
145 project_config_path,
146 global_config_path,
147 resolved_config,
148 migration_history,
149 })
150 }
151
152 pub fn is_migration_applied(&self, migration_id: &str) -> bool {
154 self.migration_history
155 .applied_migrations
156 .iter()
157 .any(|m| m.id == migration_id)
158 }
159
160 pub fn file_exists(&self, path: &str) -> bool {
162 self.repo_root.join(path).exists()
163 }
164
165 pub fn resolve_path(&self, path: &str) -> PathBuf {
167 self.repo_root.join(path)
168 }
169}
170
171pub fn check_migrations(ctx: &MigrationContext) -> Result<MigrationCheckResult> {
173 let pending: Vec<&'static Migration> = registry::MIGRATIONS
174 .iter()
175 .filter(|m| !ctx.is_migration_applied(m.id) && is_migration_applicable(ctx, m))
176 .collect();
177
178 if pending.is_empty() {
179 Ok(MigrationCheckResult::Current)
180 } else {
181 Ok(MigrationCheckResult::Pending(pending))
182 }
183}
184
185fn is_migration_applicable(ctx: &MigrationContext, migration: &Migration) -> bool {
187 match &migration.migration_type {
188 MigrationType::ConfigKeyRename { old_key, .. } => {
189 config_migrations::config_has_key(ctx, old_key)
190 }
191 MigrationType::ConfigKeyRemove { key } => config_migrations::config_has_key(ctx, key),
192 MigrationType::ConfigCiGateRewrite => {
193 config_migrations::config_has_key(ctx, "agent.ci_gate_command")
194 || config_migrations::config_has_key(ctx, "agent.ci_gate_enabled")
195 }
196 MigrationType::ConfigLegacyContractUpgrade => {
197 config_migrations::config_needs_legacy_contract_upgrade(ctx)
198 }
199 MigrationType::FileRename { old_path, new_path } => {
200 if matches!(
201 migration.id,
202 "file_cleanup_legacy_queue_json_after_jsonc_2026_02"
203 | "file_cleanup_legacy_done_json_after_jsonc_2026_02"
204 | "file_cleanup_legacy_config_json_after_jsonc_2026_02"
205 ) {
206 return ctx.file_exists(old_path) && ctx.file_exists(new_path);
207 }
208 match (*old_path, *new_path) {
209 (".ralph/queue.json", ".ralph/queue.jsonc")
210 | (".ralph/done.json", ".ralph/done.jsonc")
211 | (".ralph/config.json", ".ralph/config.jsonc") => ctx.file_exists(old_path),
212 _ => ctx.file_exists(old_path) && !ctx.file_exists(new_path),
213 }
214 }
215 MigrationType::ReadmeUpdate { from_version, .. } => {
216 if let Ok(result) =
219 crate::commands::init::readme::check_readme_current_from_root(&ctx.repo_root)
220 {
221 match result {
222 crate::commands::init::readme::ReadmeCheckResult::Current(v) => {
223 v < *from_version
224 }
225 crate::commands::init::readme::ReadmeCheckResult::Outdated {
226 current_version,
227 ..
228 } => current_version < *from_version,
229 _ => false,
230 }
231 } else {
232 false
233 }
234 }
235 }
236}
237
238pub fn apply_migration(ctx: &mut MigrationContext, migration: &Migration) -> Result<()> {
240 if ctx.is_migration_applied(migration.id) {
241 log::debug!("Migration {} already applied, skipping", migration.id);
242 return Ok(());
243 }
244
245 log::info!(
246 "Applying migration: {} - {}",
247 migration.id,
248 migration.description
249 );
250
251 match &migration.migration_type {
252 MigrationType::ConfigKeyRename { old_key, new_key } => {
253 config_migrations::apply_key_rename(ctx, old_key, new_key)
254 .with_context(|| format!("apply config key rename for {}", migration.id))?;
255 }
256 MigrationType::ConfigKeyRemove { key } => {
257 config_migrations::apply_key_remove(ctx, key)
258 .with_context(|| format!("apply config key removal for {}", migration.id))?;
259 }
260 MigrationType::ConfigCiGateRewrite => {
261 config_migrations::apply_ci_gate_rewrite(ctx)
262 .with_context(|| format!("apply CI gate rewrite for {}", migration.id))?;
263 }
264 MigrationType::ConfigLegacyContractUpgrade => {
265 config_migrations::apply_legacy_contract_upgrade(ctx)
266 .with_context(|| format!("apply legacy config upgrade for {}", migration.id))?;
267 }
268 MigrationType::FileRename { old_path, new_path } => match (*old_path, *new_path) {
269 (".ralph/queue.json", ".ralph/queue.jsonc") => {
270 file_migrations::migrate_queue_json_to_jsonc(ctx)
271 .with_context(|| format!("apply file rename for {}", migration.id))?;
272 }
273 (".ralph/done.json", ".ralph/done.jsonc") => {
274 file_migrations::migrate_done_json_to_jsonc(ctx)
275 .with_context(|| format!("apply file rename for {}", migration.id))?;
276 }
277 (".ralph/config.json", ".ralph/config.jsonc") => {
278 file_migrations::migrate_config_json_to_jsonc(ctx)
279 .with_context(|| format!("apply file rename for {}", migration.id))?;
280 }
281 _ => {
282 file_migrations::apply_file_rename(ctx, old_path, new_path)
283 .with_context(|| format!("apply file rename for {}", migration.id))?;
284 }
285 },
286 MigrationType::ReadmeUpdate { .. } => {
287 apply_readme_update(ctx)
288 .with_context(|| format!("apply README update for {}", migration.id))?;
289 }
290 }
291
292 ctx.migration_history
294 .applied_migrations
295 .push(history::AppliedMigration {
296 id: migration.id.to_string(),
297 applied_at: chrono::Utc::now(),
298 migration_type: format!("{:?}", migration.migration_type),
299 });
300
301 history::save_migration_history(&ctx.repo_root, &ctx.migration_history)
303 .with_context(|| format!("save migration history after {}", migration.id))?;
304
305 log::info!("Successfully applied migration: {}", migration.id);
306 Ok(())
307}
308
309pub fn apply_all_migrations(ctx: &mut MigrationContext) -> Result<Vec<&'static str>> {
311 let pending = match check_migrations(ctx)? {
312 MigrationCheckResult::Current => return Ok(Vec::new()),
313 MigrationCheckResult::Pending(migrations) => migrations,
314 };
315
316 let mut applied = Vec::new();
317 for migration in pending {
318 apply_migration(ctx, migration)
319 .with_context(|| format!("apply migration {}", migration.id))?;
320 applied.push(migration.id);
321 }
322
323 Ok(applied)
324}
325
326fn apply_readme_update(ctx: &MigrationContext) -> Result<()> {
328 let readme_path = ctx.repo_root.join(".ralph/README.md");
329 if !readme_path.exists() {
330 anyhow::bail!("README.md does not exist at {}", readme_path.display());
331 }
332
333 let (status, _) = crate::commands::init::readme::write_readme(&readme_path, false, true)
335 .context("write updated README")?;
336
337 match status {
338 crate::commands::init::FileInitStatus::Updated => Ok(()),
339 crate::commands::init::FileInitStatus::Created => {
340 Ok(())
342 }
343 crate::commands::init::FileInitStatus::Valid => {
344 Ok(())
346 }
347 }
348}
349
350pub fn list_migrations(ctx: &MigrationContext) -> Vec<MigrationStatus<'_>> {
352 registry::MIGRATIONS
353 .iter()
354 .map(|m| {
355 let applied = ctx.is_migration_applied(m.id);
356 let applicable = is_migration_applicable(ctx, m);
357 MigrationStatus {
358 migration: m,
359 applied,
360 applicable,
361 }
362 })
363 .collect()
364}
365
366#[derive(Debug, Clone)]
368pub struct MigrationStatus<'a> {
369 pub migration: &'a Migration,
371 pub applied: bool,
373 pub applicable: bool,
375}
376
377impl<'a> MigrationStatus<'a> {
378 pub fn status_text(&self) -> &'static str {
380 if self.applied {
381 "applied"
382 } else if self.applicable {
383 "pending"
384 } else {
385 "not applicable"
386 }
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393 use tempfile::TempDir;
394
395 fn create_test_context(dir: &TempDir) -> MigrationContext {
396 let repo_root = dir.path().to_path_buf();
397 let project_config_path = repo_root.join(".ralph/config.json");
398
399 MigrationContext {
400 repo_root,
401 project_config_path,
402 global_config_path: None,
403 resolved_config: crate::contracts::Config::default(),
404 migration_history: history::MigrationHistory::default(),
405 }
406 }
407
408 #[test]
409 fn migration_context_detects_applied_migration() {
410 let dir = TempDir::new().unwrap();
411 let mut ctx = create_test_context(&dir);
412
413 assert!(!ctx.is_migration_applied("test_migration"));
415
416 ctx.migration_history
418 .applied_migrations
419 .push(history::AppliedMigration {
420 id: "test_migration".to_string(),
421 applied_at: chrono::Utc::now(),
422 migration_type: "test".to_string(),
423 });
424
425 assert!(ctx.is_migration_applied("test_migration"));
427 }
428
429 #[test]
430 fn migration_context_file_exists_check() {
431 let dir = TempDir::new().unwrap();
432 let ctx = create_test_context(&dir);
433
434 std::fs::create_dir_all(dir.path().join(".ralph")).unwrap();
436 std::fs::write(dir.path().join(".ralph/queue.json"), "{}").unwrap();
437
438 assert!(ctx.file_exists(".ralph/queue.json"));
439 assert!(!ctx.file_exists(".ralph/done.json"));
440 }
441
442 #[test]
443 fn migration_context_discovers_repo_without_resolving_config() {
444 let dir = TempDir::new().unwrap();
445 let ralph_dir = dir.path().join(".ralph");
446 std::fs::create_dir_all(&ralph_dir).unwrap();
447 std::fs::write(
448 ralph_dir.join("config.jsonc"),
449 r#"{"version":1,"agent":{"git_commit_push_enabled":true}}"#,
450 )
451 .unwrap();
452
453 let ctx = MigrationContext::discover_from_dir(dir.path()).unwrap();
454
455 assert_eq!(ctx.repo_root, dir.path());
456 assert_eq!(ctx.project_config_path, ralph_dir.join("config.jsonc"));
457 }
458
459 #[test]
460 fn cleanup_migration_pending_when_legacy_json_remains_after_rename_migration() {
461 let dir = TempDir::new().unwrap();
462 let mut ctx = create_test_context(&dir);
463
464 std::fs::create_dir_all(dir.path().join(".ralph")).unwrap();
465 std::fs::write(dir.path().join(".ralph/queue.json"), "{}").unwrap();
466 std::fs::write(dir.path().join(".ralph/queue.jsonc"), "{}").unwrap();
467
468 ctx.migration_history
470 .applied_migrations
471 .push(history::AppliedMigration {
472 id: "file_rename_queue_json_to_jsonc_2026_02".to_string(),
473 applied_at: chrono::Utc::now(),
474 migration_type: "FileRename".to_string(),
475 });
476
477 let pending = match check_migrations(&ctx).expect("check migrations") {
478 MigrationCheckResult::Pending(pending) => pending,
479 MigrationCheckResult::Current => panic!("expected pending cleanup migration"),
480 };
481
482 let pending_ids: Vec<&str> = pending.iter().map(|m| m.id).collect();
483 assert!(
484 pending_ids.contains(&"file_cleanup_legacy_queue_json_after_jsonc_2026_02"),
485 "expected cleanup migration to be pending when legacy queue.json remains"
486 );
487 }
488}