1use crate::config;
18use anyhow::{Context, Result};
19use std::fs;
20use std::path::{Path, PathBuf};
21
22use super::MigrationContext;
23
24#[derive(Debug, Clone)]
26pub struct FileMigrationOptions {
27 pub keep_backup: bool,
29 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
42pub 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
49pub 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 if !old_full_path.exists() {
61 anyhow::bail!("Source file does not exist: {}", old_full_path.display());
62 }
63
64 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 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 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 if opts.update_config {
95 update_config_file_references(ctx, old_path, new_path)
96 .context("update config file references")?;
97 }
98
99 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
111fn update_config_file_references(
114 ctx: &MigrationContext,
115 old_path: &str,
116 new_path: &str,
117) -> Result<()> {
118 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 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
135fn update_config_file_if_needed(config_path: &Path, old_path: &str, new_path: &str) -> Result<()> {
137 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 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 let raw = fs::read_to_string(config_path)
165 .with_context(|| format!("read config {}", config_path.display()))?;
166
167 let updated = raw.replace(&format!("\"{}\"", old_path), &format!("\"{}\"", new_path));
170
171 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
185pub 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
192pub 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
198pub fn is_queue_json_to_jsonc_applicable(ctx: &MigrationContext) -> bool {
200 ctx.file_exists(".ralph/queue.json")
201}
202
203pub fn is_done_json_to_jsonc_applicable(ctx: &MigrationContext) -> bool {
205 ctx.file_exists(".ralph/done.json")
206}
207
208pub 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
214pub 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 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
249pub 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 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 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 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_file_rename(&ctx, ".ralph/queue.json", ".ralph/queue.jsonc").unwrap();
312
313 assert!(source.exists());
315 assert!(dir.path().join(".ralph/queue.jsonc").exists());
316
317 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 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 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 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 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 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_config_file_references(&ctx, ".ralph/queue.json", ".ralph/queue.jsonc").unwrap();
391
392 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 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_config_file_references(&ctx, ".ralph/done.json", ".ralph/done.jsonc").unwrap();
418
419 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 assert!(!is_queue_json_to_jsonc_applicable(&ctx));
431
432 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 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 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 assert!(dir.path().join(".ralph/queue.json").exists());
483 assert!(dir.path().join(".ralph/queue.jsonc").exists());
484
485 rollback_file_migration(&ctx, ".ralph/queue.json", ".ralph/queue.jsonc").unwrap();
487
488 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 let result = rollback_file_migration(&ctx, ".ralph/queue.json", ".ralph/queue.jsonc");
500 assert!(result.is_err());
501 }
502}