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/`).
12//! - File migration implementation details (see `file_migrations/`).
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::{env, 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    /// Upgrade pre-0.3 config contract keys and version markers.
66    ConfigLegacyContractUpgrade,
67    /// Rename/move a file.
68    FileRename {
69        /// Path to the old file, relative to repo root.
70        old_path: &'static str,
71        /// Path to the new file, relative to repo root.
72        new_path: &'static str,
73    },
74    /// Update README template.
75    ReadmeUpdate {
76        /// The version to update from (inclusive).
77        from_version: u32,
78        /// The version to update to.
79        to_version: u32,
80    },
81}
82
83/// Context for migration operations.
84#[derive(Debug, Clone)]
85pub struct MigrationContext {
86    /// Repository root directory.
87    pub repo_root: PathBuf,
88    /// Path to project config file.
89    pub project_config_path: PathBuf,
90    /// Path to global config file (if any).
91    pub global_config_path: Option<PathBuf>,
92    /// Currently resolved configuration.
93    pub resolved_config: crate::contracts::Config,
94    /// Loaded migration history.
95    pub migration_history: history::MigrationHistory,
96}
97
98impl MigrationContext {
99    /// Create a new migration context from resolved config.
100    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    /// Create a migration context from the current working directory without
113    /// requiring configuration parsing to succeed.
114    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    /// Create a migration context from an arbitrary directory without
120    /// requiring configuration parsing to succeed.
121    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    /// Check if a migration has already been applied.
153    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    /// Check if a file exists relative to repo root.
161    pub fn file_exists(&self, path: &str) -> bool {
162        self.repo_root.join(path).exists()
163    }
164
165    /// Get full path for a repo-relative path.
166    pub fn resolve_path(&self, path: &str) -> PathBuf {
167        self.repo_root.join(path)
168    }
169}
170
171/// Check for pending migrations without applying them.
172pub 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
185/// Check if a specific migration is applicable in the current context.
186fn 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            // README update is applicable if current version is less than target
217            // This is handled separately by the README module
218            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
238/// Apply a single migration.
239pub 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    // Record the migration as applied
293    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    // Save the updated history
302    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
309/// Apply all pending migrations.
310pub 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
326/// Apply README update migration.
327fn 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    // Use the existing README write functionality
334    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            // This shouldn't happen since we're updating
341            Ok(())
342        }
343        crate::commands::init::FileInitStatus::Valid => {
344            // Already current
345            Ok(())
346        }
347    }
348}
349
350/// List all migrations with their status.
351pub 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/// Status of a migration for display.
367#[derive(Debug, Clone)]
368pub struct MigrationStatus<'a> {
369    /// The migration definition.
370    pub migration: &'a Migration,
371    /// Whether this migration has been applied.
372    pub applied: bool,
373    /// Whether this migration is applicable in the current context.
374    pub applicable: bool,
375}
376
377impl<'a> MigrationStatus<'a> {
378    /// Get a display status string.
379    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        // Initially no migrations applied
414        assert!(!ctx.is_migration_applied("test_migration"));
415
416        // Add a migration to history
417        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        // Now it should be detected as applied
426        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        // Create a test file
435        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        // Simulate historical state where rename migration was already recorded.
469        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}