Skip to main content

ralph/migration/
file_migrations.rs

1//! File migration utilities for renaming/moving files.
2//!
3//! Responsibilities:
4//! - Safely rename/move files with backup and rollback capability.
5//! - Update config references when files are moved.
6//! - Handle queue.json to queue.jsonc migration.
7//!
8//! Not handled here:
9//! - Config key renames (see `config_migrations.rs`).
10//! - Migration history tracking (see `history.rs`).
11//!
12//! Invariants/assumptions:
13//! - Generic file rename helpers can keep backups before moving.
14//! - Config file references are updated after file moves.
15//! - JSON-to-JSONC migrations remove legacy `.json` files after `.jsonc` is established.
16
17use crate::config;
18use anyhow::{Context, Result};
19use std::fs;
20use std::path::{Path, PathBuf};
21
22use super::MigrationContext;
23
24/// Options for file migration.
25#[derive(Debug, Clone)]
26pub struct FileMigrationOptions {
27    /// Whether to keep the original file as a backup.
28    pub keep_backup: bool,
29    /// Whether to update config file references.
30    pub update_config: bool,
31}
32
33impl Default for FileMigrationOptions {
34    fn default() -> Self {
35        Self {
36            keep_backup: true,
37            update_config: true,
38        }
39    }
40}
41
42/// Apply a file rename migration.
43/// Copies content from old_path to new_path and optionally updates config.
44pub fn apply_file_rename(ctx: &MigrationContext, old_path: &str, new_path: &str) -> Result<()> {
45    let opts = FileMigrationOptions::default();
46    apply_file_rename_with_options(ctx, old_path, new_path, &opts)
47}
48
49/// Apply a file rename migration with custom options.
50pub fn apply_file_rename_with_options(
51    ctx: &MigrationContext,
52    old_path: &str,
53    new_path: &str,
54    opts: &FileMigrationOptions,
55) -> Result<()> {
56    let old_full_path = ctx.resolve_path(old_path);
57    let new_full_path = ctx.resolve_path(new_path);
58
59    // Validate source exists
60    if !old_full_path.exists() {
61        anyhow::bail!("Source file does not exist: {}", old_full_path.display());
62    }
63
64    // Validate destination doesn't exist (unless it's the same file)
65    if new_full_path.exists() && old_full_path != new_full_path {
66        anyhow::bail!(
67            "Destination file already exists: {}",
68            new_full_path.display()
69        );
70    }
71
72    // Ensure parent directory exists for destination
73    if let Some(parent) = new_full_path.parent() {
74        fs::create_dir_all(parent)
75            .with_context(|| format!("create parent directory {}", parent.display()))?;
76    }
77
78    // Copy the file (preserves content, creates new file)
79    fs::copy(&old_full_path, &new_full_path).with_context(|| {
80        format!(
81            "copy {} to {}",
82            old_full_path.display(),
83            new_full_path.display()
84        )
85    })?;
86
87    log::info!(
88        "Migrated file from {} to {}",
89        old_full_path.display(),
90        new_full_path.display()
91    );
92
93    // Update config references if needed
94    if opts.update_config {
95        update_config_file_references(ctx, old_path, new_path)
96            .context("update config file references")?;
97    }
98
99    // Remove original if not keeping backup
100    if !opts.keep_backup {
101        fs::remove_file(&old_full_path)
102            .with_context(|| format!("remove original file {}", old_full_path.display()))?;
103        log::debug!("Removed original file {}", old_full_path.display());
104    } else {
105        log::debug!("Kept original file {} as backup", old_full_path.display());
106    }
107
108    Ok(())
109}
110
111/// Update config file references after a file move.
112/// Updates queue.file and queue.done_file if they match the old path.
113fn update_config_file_references(
114    ctx: &MigrationContext,
115    old_path: &str,
116    new_path: &str,
117) -> Result<()> {
118    // Check project config
119    if ctx.project_config_path.exists() {
120        update_config_file_if_needed(&ctx.project_config_path, old_path, new_path)
121            .context("update project config file references")?;
122    }
123
124    // Check global config
125    if let Some(global_path) = &ctx.global_config_path
126        && global_path.exists()
127    {
128        update_config_file_if_needed(global_path, old_path, new_path)
129            .context("update global config file references")?;
130    }
131
132    Ok(())
133}
134
135/// Update a specific config file's file references.
136fn update_config_file_if_needed(config_path: &Path, old_path: &str, new_path: &str) -> Result<()> {
137    // Load the config layer
138    let layer = config::load_layer(config_path)
139        .with_context(|| format!("load config from {}", config_path.display()))?;
140
141    let old_path_buf = PathBuf::from(old_path);
142    let _new_path_buf = PathBuf::from(new_path);
143
144    // Check if any file references need updating
145    let mut needs_update = false;
146
147    if let Some(ref file) = layer.queue.file
148        && file == &old_path_buf
149    {
150        needs_update = true;
151    }
152
153    if let Some(ref done_file) = layer.queue.done_file
154        && done_file == &old_path_buf
155    {
156        needs_update = true;
157    }
158
159    if !needs_update {
160        return Ok(());
161    }
162
163    // We need to do a text-based replacement to preserve JSONC comments
164    let raw = fs::read_to_string(config_path)
165        .with_context(|| format!("read config {}", config_path.display()))?;
166
167    // Replace the old path with the new path
168    // We use a simple string replacement for the specific path value
169    let updated = raw.replace(&format!("\"{}\"", old_path), &format!("\"{}\"", new_path));
170
171    // Write back
172    crate::fsutil::write_atomic(config_path, updated.as_bytes())
173        .with_context(|| format!("write updated config to {}", config_path.display()))?;
174
175    log::info!(
176        "Updated file reference in {}: {} -> {}",
177        config_path.display(),
178        old_path,
179        new_path
180    );
181
182    Ok(())
183}
184
185/// Migrate queue.json to queue.jsonc.
186/// This is a convenience function for the common case.
187pub fn migrate_queue_json_to_jsonc(ctx: &MigrationContext) -> Result<()> {
188    migrate_json_to_jsonc(ctx, ".ralph/queue.json", ".ralph/queue.jsonc")
189        .context("migrate queue.json to queue.jsonc")
190}
191
192/// Migrate done.json to done.jsonc.
193pub fn migrate_done_json_to_jsonc(ctx: &MigrationContext) -> Result<()> {
194    migrate_json_to_jsonc(ctx, ".ralph/done.json", ".ralph/done.jsonc")
195        .context("migrate done.json to done.jsonc")
196}
197
198/// Check if a migration from queue.json to queue.jsonc is applicable.
199pub fn is_queue_json_to_jsonc_applicable(ctx: &MigrationContext) -> bool {
200    ctx.file_exists(".ralph/queue.json")
201}
202
203/// Check if a migration from done.json to done.jsonc is applicable.
204pub fn is_done_json_to_jsonc_applicable(ctx: &MigrationContext) -> bool {
205    ctx.file_exists(".ralph/done.json")
206}
207
208/// Migrate config.json to config.jsonc.
209pub fn migrate_config_json_to_jsonc(ctx: &MigrationContext) -> Result<()> {
210    migrate_json_to_jsonc(ctx, ".ralph/config.json", ".ralph/config.jsonc")
211        .context("migrate config.json to config.jsonc")
212}
213
214/// Check if a migration from config.json to config.jsonc is applicable.
215pub fn is_config_json_to_jsonc_applicable(ctx: &MigrationContext) -> bool {
216    ctx.file_exists(".ralph/config.json")
217}
218
219fn migrate_json_to_jsonc(ctx: &MigrationContext, old_path: &str, new_path: &str) -> Result<()> {
220    let old_full_path = ctx.resolve_path(old_path);
221    let new_full_path = ctx.resolve_path(new_path);
222
223    if !old_full_path.exists() {
224        return Ok(());
225    }
226
227    if new_full_path.exists() {
228        // Queue/done/config references may still point at legacy .json paths even when
229        // .jsonc already exists. Normalize references before deleting legacy files.
230        update_config_file_references(ctx, old_path, new_path)
231            .context("update config references for established jsonc migration")?;
232        fs::remove_file(&old_full_path)
233            .with_context(|| format!("remove legacy file {}", old_full_path.display()))?;
234        log::info!(
235            "Removed legacy file {} because {} already exists",
236            old_full_path.display(),
237            new_full_path.display()
238        );
239        return Ok(());
240    }
241
242    let opts = FileMigrationOptions {
243        keep_backup: false,
244        update_config: true,
245    };
246    apply_file_rename_with_options(ctx, old_path, new_path, &opts)
247}
248
249/// Rollback a file migration by restoring from backup.
250/// This removes the new file and restores the original.
251pub fn rollback_file_migration(
252    ctx: &MigrationContext,
253    old_path: &str,
254    new_path: &str,
255) -> Result<()> {
256    let old_full_path = ctx.resolve_path(old_path);
257    let new_full_path = ctx.resolve_path(new_path);
258
259    // Check that original exists (backup)
260    if !old_full_path.exists() {
261        anyhow::bail!(
262            "Cannot rollback: original file {} does not exist",
263            old_full_path.display()
264        );
265    }
266
267    // Remove the new file
268    if new_full_path.exists() {
269        fs::remove_file(&new_full_path)
270            .with_context(|| format!("remove migrated file {}", new_full_path.display()))?;
271    }
272
273    log::info!(
274        "Rolled back file migration: restored {}, removed {}",
275        old_full_path.display(),
276        new_full_path.display()
277    );
278
279    Ok(())
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use tempfile::TempDir;
286
287    fn create_test_context(dir: &TempDir) -> MigrationContext {
288        let repo_root = dir.path().to_path_buf();
289        let project_config_path = repo_root.join(".ralph/config.json");
290
291        MigrationContext {
292            repo_root,
293            project_config_path,
294            global_config_path: None,
295            resolved_config: crate::contracts::Config::default(),
296            migration_history: super::super::history::MigrationHistory::default(),
297        }
298    }
299
300    #[test]
301    fn apply_file_rename_copies_file() {
302        let dir = TempDir::new().unwrap();
303        let ctx = create_test_context(&dir);
304
305        // Create source file
306        fs::create_dir_all(dir.path().join(".ralph")).unwrap();
307        let source = dir.path().join(".ralph/queue.json");
308        fs::write(&source, "{\"version\": 1}").unwrap();
309
310        // Apply migration
311        apply_file_rename(&ctx, ".ralph/queue.json", ".ralph/queue.jsonc").unwrap();
312
313        // Both files should exist (backup kept by default)
314        assert!(source.exists());
315        assert!(dir.path().join(".ralph/queue.jsonc").exists());
316
317        // Content should be identical
318        let original_content = fs::read_to_string(&source).unwrap();
319        let new_content = fs::read_to_string(dir.path().join(".ralph/queue.jsonc")).unwrap();
320        assert_eq!(original_content, new_content);
321    }
322
323    #[test]
324    fn apply_file_rename_without_backup_removes_original() {
325        let dir = TempDir::new().unwrap();
326        let ctx = create_test_context(&dir);
327
328        // Create source file
329        fs::create_dir_all(dir.path().join(".ralph")).unwrap();
330        let source = dir.path().join(".ralph/queue.json");
331        fs::write(&source, "{\"version\": 1}").unwrap();
332
333        // Apply migration without backup
334        let opts = FileMigrationOptions {
335            keep_backup: false,
336            update_config: false,
337        };
338        apply_file_rename_with_options(&ctx, ".ralph/queue.json", ".ralph/queue.jsonc", &opts)
339            .unwrap();
340
341        // Original should be gone, new should exist
342        assert!(!source.exists());
343        assert!(dir.path().join(".ralph/queue.jsonc").exists());
344    }
345
346    #[test]
347    fn apply_file_rename_fails_if_source_missing() {
348        let dir = TempDir::new().unwrap();
349        let ctx = create_test_context(&dir);
350
351        let result = apply_file_rename(&ctx, ".ralph/queue.json", ".ralph/queue.jsonc");
352        assert!(result.is_err());
353        assert!(result.unwrap_err().to_string().contains("does not exist"));
354    }
355
356    #[test]
357    fn apply_file_rename_fails_if_destination_exists() {
358        let dir = TempDir::new().unwrap();
359        let ctx = create_test_context(&dir);
360
361        // Create both files
362        fs::create_dir_all(dir.path().join(".ralph")).unwrap();
363        fs::write(dir.path().join(".ralph/queue.json"), "{}").unwrap();
364        fs::write(dir.path().join(".ralph/queue.jsonc"), "{}").unwrap();
365
366        let result = apply_file_rename(&ctx, ".ralph/queue.json", ".ralph/queue.jsonc");
367        assert!(result.is_err());
368        assert!(result.unwrap_err().to_string().contains("already exists"));
369    }
370
371    #[test]
372    fn update_config_file_references_updates_queue_file() {
373        let dir = TempDir::new().unwrap();
374        let ctx = create_test_context(&dir);
375
376        // Create config with queue.file
377        fs::create_dir_all(dir.path().join(".ralph")).unwrap();
378        fs::write(
379            &ctx.project_config_path,
380            r#"{
381                "version": 1,
382                "queue": {
383                    "file": ".ralph/queue.json"
384                }
385            }"#,
386        )
387        .unwrap();
388
389        // Update references
390        update_config_file_references(&ctx, ".ralph/queue.json", ".ralph/queue.jsonc").unwrap();
391
392        // Verify update
393        let content = fs::read_to_string(&ctx.project_config_path).unwrap();
394        assert!(content.contains("\"file\": \".ralph/queue.jsonc\""));
395        assert!(!content.contains("\"file\": \".ralph/queue.json\""));
396    }
397
398    #[test]
399    fn update_config_file_references_updates_done_file() {
400        let dir = TempDir::new().unwrap();
401        let ctx = create_test_context(&dir);
402
403        // Create config with queue.done_file
404        fs::create_dir_all(dir.path().join(".ralph")).unwrap();
405        fs::write(
406            &ctx.project_config_path,
407            r#"{
408                "version": 1,
409                "queue": {
410                    "done_file": ".ralph/done.json"
411                }
412            }"#,
413        )
414        .unwrap();
415
416        // Update references
417        update_config_file_references(&ctx, ".ralph/done.json", ".ralph/done.jsonc").unwrap();
418
419        // Verify update
420        let content = fs::read_to_string(&ctx.project_config_path).unwrap();
421        assert!(content.contains("\"done_file\": \".ralph/done.jsonc\""));
422    }
423
424    #[test]
425    fn is_queue_json_to_jsonc_applicable_detects_correct_state() {
426        let dir = TempDir::new().unwrap();
427        let ctx = create_test_context(&dir);
428
429        // Neither file exists
430        assert!(!is_queue_json_to_jsonc_applicable(&ctx));
431
432        // Only queue.json exists
433        fs::create_dir_all(dir.path().join(".ralph")).unwrap();
434        fs::write(dir.path().join(".ralph/queue.json"), "{}").unwrap();
435        assert!(is_queue_json_to_jsonc_applicable(&ctx));
436
437        // Both exist
438        fs::write(dir.path().join(".ralph/queue.jsonc"), "{}").unwrap();
439        assert!(is_queue_json_to_jsonc_applicable(&ctx));
440    }
441
442    #[test]
443    fn migrate_queue_json_to_jsonc_removes_legacy_file_when_jsonc_absent() {
444        let dir = TempDir::new().unwrap();
445        let ctx = create_test_context(&dir);
446
447        fs::create_dir_all(dir.path().join(".ralph")).unwrap();
448        fs::write(dir.path().join(".ralph/queue.json"), "{\"version\": 1}").unwrap();
449
450        migrate_queue_json_to_jsonc(&ctx).unwrap();
451
452        assert!(!dir.path().join(".ralph/queue.json").exists());
453        assert!(dir.path().join(".ralph/queue.jsonc").exists());
454    }
455
456    #[test]
457    fn migrate_queue_json_to_jsonc_removes_legacy_file_when_jsonc_already_exists() {
458        let dir = TempDir::new().unwrap();
459        let ctx = create_test_context(&dir);
460
461        fs::create_dir_all(dir.path().join(".ralph")).unwrap();
462        fs::write(dir.path().join(".ralph/queue.json"), "{\"legacy\": true}").unwrap();
463        fs::write(dir.path().join(".ralph/queue.jsonc"), "{\"version\": 1}").unwrap();
464
465        migrate_queue_json_to_jsonc(&ctx).unwrap();
466
467        assert!(!dir.path().join(".ralph/queue.json").exists());
468        assert!(dir.path().join(".ralph/queue.jsonc").exists());
469    }
470
471    #[test]
472    fn rollback_file_migration_restores_original() {
473        let dir = TempDir::new().unwrap();
474        let ctx = create_test_context(&dir);
475
476        // Create source file and migrate
477        fs::create_dir_all(dir.path().join(".ralph")).unwrap();
478        fs::write(dir.path().join(".ralph/queue.json"), "{\"version\": 1}").unwrap();
479        apply_file_rename(&ctx, ".ralph/queue.json", ".ralph/queue.jsonc").unwrap();
480
481        // Verify both exist
482        assert!(dir.path().join(".ralph/queue.json").exists());
483        assert!(dir.path().join(".ralph/queue.jsonc").exists());
484
485        // Rollback
486        rollback_file_migration(&ctx, ".ralph/queue.json", ".ralph/queue.jsonc").unwrap();
487
488        // Original should exist, new should be gone
489        assert!(dir.path().join(".ralph/queue.json").exists());
490        assert!(!dir.path().join(".ralph/queue.jsonc").exists());
491    }
492
493    #[test]
494    fn rollback_fails_if_original_missing() {
495        let dir = TempDir::new().unwrap();
496        let ctx = create_test_context(&dir);
497
498        // Try to rollback without original
499        let result = rollback_file_migration(&ctx, ".ralph/queue.json", ".ralph/queue.jsonc");
500        assert!(result.is_err());
501    }
502}