Skip to main content

ralph/migration/
mod.rs

1//! Migration system for config and project file changes.
2//!
3//! Responsibilities:
4//! - Track and apply migrations for config key renames/removals, file format changes, and README updates.
5//! - Provide a registry-based system for defining migrations with unique IDs.
6//! - Support safe migration with backup/rollback capability.
7//! - Preserve JSONC comments when modifying config files.
8//!
9//! Not handled here:
10//! - Direct file I/O for migration history (see `history.rs`).
11//! - Config key rename implementation details (see `config_migrations.rs`).
12//! - File migration implementation details (see `file_migrations.rs`).
13//!
14//! Invariants/assumptions:
15//! - Migrations are idempotent: running the same migration twice is a no-op.
16//! - Migration history is stored in `.ralph/cache/migrations.jsonc`.
17//! - All migrations have a unique ID and are tracked in the registry.
18
19use 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/// Result of checking migration status.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum MigrationCheckResult {
31    /// No pending migrations.
32    Current,
33    /// Pending migrations available.
34    Pending(Vec<&'static Migration>),
35}
36
37/// A single migration definition.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Migration {
40    /// Unique identifier for this migration (e.g., "config_key_rename_2026_01").
41    pub id: &'static str,
42    /// Human-readable description of what this migration does.
43    pub description: &'static str,
44    /// The type of migration to perform.
45    pub migration_type: MigrationType,
46}
47
48/// Types of migrations supported.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum MigrationType {
51    /// Rename a config key (old_key -> new_key).
52    ConfigKeyRename {
53        /// Dot-separated path to the old key (e.g., "agent.runner_cli").
54        old_key: &'static str,
55        /// Dot-separated path to the new key (e.g., "agent.runner_options").
56        new_key: &'static str,
57    },
58    /// Remove a deprecated config key.
59    ConfigKeyRemove {
60        /// Dot-separated path to the key to remove (e.g., "agent.legacy_flag").
61        key: &'static str,
62    },
63    /// Rewrite legacy CI gate string config to structured argv/shell config.
64    ConfigCiGateRewrite,
65    /// Rename/move a file.
66    FileRename {
67        /// Path to the old file, relative to repo root.
68        old_path: &'static str,
69        /// Path to the new file, relative to repo root.
70        new_path: &'static str,
71    },
72    /// Update README template.
73    ReadmeUpdate {
74        /// The version to update from (inclusive).
75        from_version: u32,
76        /// The version to update to.
77        to_version: u32,
78    },
79}
80
81/// Context for migration operations.
82#[derive(Debug, Clone)]
83pub struct MigrationContext {
84    /// Repository root directory.
85    pub repo_root: PathBuf,
86    /// Path to project config file.
87    pub project_config_path: PathBuf,
88    /// Path to global config file (if any).
89    pub global_config_path: Option<PathBuf>,
90    /// Currently resolved configuration.
91    pub resolved_config: crate::contracts::Config,
92    /// Loaded migration history.
93    pub migration_history: history::MigrationHistory,
94}
95
96impl MigrationContext {
97    /// Create a new migration context from resolved config.
98    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    /// Check if a migration has already been applied.
115    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    /// Check if a file exists relative to repo root.
123    pub fn file_exists(&self, path: &str) -> bool {
124        self.repo_root.join(path).exists()
125    }
126
127    /// Get full path for a repo-relative path.
128    pub fn resolve_path(&self, path: &str) -> PathBuf {
129        self.repo_root.join(path)
130    }
131}
132
133/// Check for pending migrations without applying them.
134pub 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
147/// Check if a specific migration is applicable in the current context.
148fn 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            // README update is applicable if current version is less than target
176            // This is handled separately by the README module
177            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
197/// Apply a single migration.
198pub 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    // Record the migration as applied
248    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    // Save the updated history
257    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
264/// Apply all pending migrations.
265pub 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
281/// Apply README update migration.
282fn 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    // Use the existing README write functionality
289    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            // This shouldn't happen since we're updating
296            Ok(())
297        }
298        crate::commands::init::FileInitStatus::Valid => {
299            // Already current
300            Ok(())
301        }
302    }
303}
304
305/// List all migrations with their status.
306pub 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/// Status of a migration for display.
322#[derive(Debug, Clone)]
323pub struct MigrationStatus<'a> {
324    /// The migration definition.
325    pub migration: &'a Migration,
326    /// Whether this migration has been applied.
327    pub applied: bool,
328    /// Whether this migration is applicable in the current context.
329    pub applicable: bool,
330}
331
332impl<'a> MigrationStatus<'a> {
333    /// Get a display status string.
334    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        // Initially no migrations applied
369        assert!(!ctx.is_migration_applied("test_migration"));
370
371        // Add a migration to history
372        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        // Now it should be detected as applied
381        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        // Create a test file
390        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        // Simulate historical state where rename migration was already recorded.
407        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}