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    /// Rename/move a file.
64    FileRename {
65        /// Path to the old file, relative to repo root.
66        old_path: &'static str,
67        /// Path to the new file, relative to repo root.
68        new_path: &'static str,
69    },
70    /// Update README template.
71    ReadmeUpdate {
72        /// The version to update from (inclusive).
73        from_version: u32,
74        /// The version to update to.
75        to_version: u32,
76    },
77}
78
79/// Context for migration operations.
80#[derive(Debug, Clone)]
81pub struct MigrationContext {
82    /// Repository root directory.
83    pub repo_root: PathBuf,
84    /// Path to project config file.
85    pub project_config_path: PathBuf,
86    /// Path to global config file (if any).
87    pub global_config_path: Option<PathBuf>,
88    /// Currently resolved configuration.
89    pub resolved_config: crate::contracts::Config,
90    /// Loaded migration history.
91    pub migration_history: history::MigrationHistory,
92}
93
94impl MigrationContext {
95    /// Create a new migration context from resolved config.
96    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    /// Check if a migration has already been applied.
113    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    /// Check if a file exists relative to repo root.
121    pub fn file_exists(&self, path: &str) -> bool {
122        self.repo_root.join(path).exists()
123    }
124
125    /// Get full path for a repo-relative path.
126    pub fn resolve_path(&self, path: &str) -> PathBuf {
127        self.repo_root.join(path)
128    }
129}
130
131/// Check for pending migrations without applying them.
132pub 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
145/// Check if a specific migration is applicable in the current context.
146fn 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            // README update is applicable if current version is less than target
170            // This is handled separately by the README module
171            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
191/// Apply a single migration.
192pub 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    // Record the migration as applied
238    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    // Save the updated history
247    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
254/// Apply all pending migrations.
255pub 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
271/// Apply README update migration.
272fn 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    // Use the existing README write functionality
279    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            // This shouldn't happen since we're updating
286            Ok(())
287        }
288        crate::commands::init::FileInitStatus::Valid => {
289            // Already current
290            Ok(())
291        }
292    }
293}
294
295/// List all migrations with their status.
296pub 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/// Status of a migration for display.
312#[derive(Debug, Clone)]
313pub struct MigrationStatus<'a> {
314    /// The migration definition.
315    pub migration: &'a Migration,
316    /// Whether this migration has been applied.
317    pub applied: bool,
318    /// Whether this migration is applicable in the current context.
319    pub applicable: bool,
320}
321
322impl<'a> MigrationStatus<'a> {
323    /// Get a display status string.
324    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        // Initially no migrations applied
359        assert!(!ctx.is_migration_applied("test_migration"));
360
361        // Add a migration to history
362        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        // Now it should be detected as applied
371        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        // Create a test file
380        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        // Simulate historical state where rename migration was already recorded.
397        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}