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