ralph_workflow/files/io/backup.rs
1//! Backup management for PROMPT.md.
2//!
3//! This module handles creation and rotation of PROMPT.md backups to protect
4//! against accidental deletion or modification.
5
6use std::fs;
7use std::io;
8use std::path::Path;
9
10use super::integrity;
11use crate::workspace::Workspace;
12
13/// Create a backup of PROMPT.md to protect against accidental deletion.
14///
15/// This function copies PROMPT.md to `.agent/PROMPT.md.backup` and sets
16/// the backup file to read-only permissions to make accidental deletion harder.
17///
18/// With backup rotation enabled (the default), this maintains up to 3 backup
19/// versions: `.agent/PROMPT.md.backup`, `.agent/PROMPT.md.backup.1`, and
20/// `.agent/PROMPT.md.backup.2`.
21///
22/// # Behavior
23///
24/// - If PROMPT.md doesn't exist, returns `Ok(())` (nothing to backup)
25/// - Creates the `.agent` directory if it doesn't exist
26/// - Rotates existing backups: backup.2 → deleted, backup.1 → backup.2, backup → backup.1
27/// - Uses atomic write to ensure backup file integrity
28/// - Sets all backup files to read-only (best-effort; failures don't error)
29/// - Returns a warning string in the Ok variant if read-only setting fails
30///
31/// # Returns
32///
33/// Returns `io::Result<Option<String>>` where:
34/// - `Ok(None)` - backup created and read-only set successfully
35/// - `Ok(Some(warning))` - backup created but read-only couldn't be set
36/// - `Err(e)` - if the backup cannot be created
37///
38/// **Note:** This function uses the current working directory for paths.
39/// For explicit path control, use [`create_prompt_backup_at`] instead.
40pub fn create_prompt_backup() -> io::Result<Option<String>> {
41 create_prompt_backup_at(Path::new("."))
42}
43
44/// Create a backup of PROMPT.md at a specific repository path.
45///
46/// # Arguments
47///
48/// * `repo_root` - Path to the repository root
49///
50/// # Returns
51///
52/// Returns `io::Result<Option<String>>` where:
53/// - `Ok(None)` - backup created and read-only set successfully
54/// - `Ok(Some(warning))` - backup created but read-only couldn't be set
55/// - `Err(e)` - if the backup cannot be created
56pub fn create_prompt_backup_at(repo_root: &Path) -> io::Result<Option<String>> {
57 let prompt_path = repo_root.join("PROMPT.md");
58
59 // If PROMPT.md doesn't exist, that's fine - nothing to backup
60 if !prompt_path.exists() {
61 return Ok(None);
62 }
63
64 // Ensure .agent directory exists
65 let agent_dir = repo_root.join(".agent");
66 let backup_base = agent_dir.join("PROMPT.md.backup");
67 fs::create_dir_all(&agent_dir)?;
68
69 // Read PROMPT.md content
70 let content = fs::read_to_string(&prompt_path).map_err(|e| {
71 io::Error::new(
72 e.kind(),
73 format!("Failed to read PROMPT.md for backup: {e}"),
74 )
75 })?;
76
77 // Backup rotation: .2 → deleted, .1 → .2, .backup → .1
78 let backup_2 = agent_dir.join("PROMPT.md.backup.2");
79 let backup_1 = agent_dir.join("PROMPT.md.backup.1");
80
81 // Delete oldest backup if it exists
82 let _ = fs::remove_file(&backup_2);
83
84 // Rotate .1 → .2
85 if backup_1.exists() {
86 let _ = fs::rename(&backup_1, &backup_2);
87 }
88
89 // Rotate .backup → .1
90 if backup_base.exists() {
91 let _ = fs::rename(&backup_base, &backup_1);
92 }
93
94 // Write new backup atomically
95 integrity::write_file_atomic(&backup_base, &content)
96 .map_err(|e| io::Error::new(e.kind(), format!("Failed to write PROMPT.md backup: {e}")))?;
97
98 // Set read-only permissions on all backups and track any failure
99 let mut readonly_warning = None;
100
101 // Helper to set read-only permissions on a file
102 let set_readonly = |path: &Path| -> io::Result<()> {
103 #[cfg(unix)]
104 {
105 use std::os::unix::fs::PermissionsExt;
106 if let Ok(metadata) = fs::metadata(path) {
107 let mut perms = metadata.permissions();
108 perms.set_mode(0o444);
109 if fs::set_permissions(path, perms).is_err() {
110 return Err(io::Error::new(
111 io::ErrorKind::PermissionDenied,
112 format!("Failed to set read-only on {}", path.display()),
113 ));
114 }
115 }
116 }
117
118 #[cfg(windows)]
119 {
120 if let Ok(metadata) = fs::metadata(path) {
121 let mut perms = metadata.permissions();
122 perms.set_readonly(true);
123 if fs::set_permissions(path, perms).is_err() {
124 return Err(io::Error::new(
125 io::ErrorKind::PermissionDenied,
126 format!("Failed to set read-only on {:?}", path),
127 ));
128 }
129 }
130 }
131 Ok(())
132 };
133
134 // Try to set read-only on all backup files (best-effort)
135 for backup_path in [&backup_base, &backup_1, &backup_2] {
136 if backup_path.exists() {
137 if let Err(e) = set_readonly(backup_path) {
138 if readonly_warning.is_none() {
139 readonly_warning = Some(e.to_string());
140 }
141 }
142 }
143 }
144
145 Ok(readonly_warning)
146}
147
148/// Make PROMPT.md read-only to protect against accidental deletion.
149///
150/// This function sets read-only permissions on PROMPT.md to make accidental
151/// deletion harder. This is a best-effort protection - agents with shell
152/// access could potentially chmod the file.
153///
154/// # Behavior
155///
156/// - If PROMPT.md doesn't exist, returns `Ok(None)` (nothing to protect)
157/// - Uses platform-specific permission setting
158/// - Returns a warning string if setting permissions fails (best-effort)
159///
160/// # Returns
161///
162/// Returns `Ok(Option<String>)` where:
163/// - `Ok(None)` - permissions set successfully or file doesn't exist
164/// - `Ok(Some(warning))` - couldn't set read-only permissions
165///
166/// **Note:** This function uses the current working directory for paths.
167/// For explicit path control, use [`make_prompt_read_only_at`] instead.
168pub fn make_prompt_read_only() -> Option<String> {
169 make_prompt_read_only_at(Path::new("."))
170}
171
172/// Make PROMPT.md read-only at a specific repository path.
173///
174/// # Arguments
175///
176/// * `repo_root` - Path to the repository root
177///
178/// # Returns
179///
180/// Returns `Option<String>` where:
181/// - `None` - permissions set successfully or file doesn't exist
182/// - `Some(warning)` - couldn't set read-only permissions
183pub fn make_prompt_read_only_at(repo_root: &Path) -> Option<String> {
184 let prompt_path = repo_root.join("PROMPT.md");
185
186 // If PROMPT.md doesn't exist, that's fine - nothing to protect
187 if !prompt_path.exists() {
188 return None;
189 }
190
191 // Try to set read-only permissions and track any failure
192 let mut readonly_warning = None;
193
194 #[cfg(unix)]
195 {
196 use std::os::unix::fs::PermissionsExt;
197 match fs::metadata(&prompt_path) {
198 Ok(metadata) => {
199 let mut perms = metadata.permissions();
200 perms.set_mode(0o444); // Read-only for all
201 if fs::set_permissions(&prompt_path, perms).is_err() {
202 readonly_warning =
203 Some("Failed to set read-only permissions on PROMPT.md".to_string());
204 }
205 }
206 Err(_) => {
207 readonly_warning = Some("Failed to read PROMPT.md metadata".to_string());
208 }
209 }
210 }
211
212 #[cfg(windows)]
213 {
214 match fs::metadata(&prompt_path) {
215 Ok(metadata) => {
216 let mut perms = metadata.permissions();
217 perms.set_readonly(true);
218 if fs::set_permissions(&prompt_path, perms).is_err() {
219 readonly_warning =
220 Some("Failed to set read-only permissions on PROMPT.md".to_string());
221 }
222 }
223 Err(_) => {
224 readonly_warning = Some("Failed to read PROMPT.md metadata".to_string());
225 }
226 }
227 }
228
229 readonly_warning
230}
231
232/// Make PROMPT.md writable again after pipeline completion.
233///
234/// This function restores write permissions on PROMPT.md after Ralph exits.
235/// It reverses the `make_prompt_read_only` operation so users can edit the file
236/// normally when Ralph is not running.
237///
238/// # Behavior
239///
240/// - If PROMPT.md doesn't exist, returns `None` (nothing to modify)
241/// - Uses platform-specific permission setting
242/// - Returns a warning string if setting permissions fails (best-effort)
243///
244/// # Returns
245///
246/// Returns `Option<String>` where:
247/// - `None` - permissions restored successfully or file doesn't exist
248/// - `Some(warning)` - couldn't restore write permissions
249///
250/// **Note:** This function uses the current working directory for paths.
251/// For explicit path control, use [`make_prompt_writable_at`] instead.
252pub fn make_prompt_writable() -> Option<String> {
253 make_prompt_writable_at(Path::new("."))
254}
255
256/// Make PROMPT.md writable again at a specific repository path.
257///
258/// # Arguments
259///
260/// * `repo_root` - Path to the repository root
261///
262/// # Returns
263///
264/// Returns `Option<String>` where:
265/// - `None` - permissions restored successfully or file doesn't exist
266/// - `Some(warning)` - couldn't restore write permissions
267pub fn make_prompt_writable_at(repo_root: &Path) -> Option<String> {
268 let prompt_path = repo_root.join("PROMPT.md");
269
270 // If PROMPT.md doesn't exist, that's fine - nothing to modify
271 if !prompt_path.exists() {
272 return None;
273 }
274
275 // Try to restore write permissions and track any failure
276 let mut writable_warning = None;
277
278 #[cfg(unix)]
279 {
280 use std::os::unix::fs::PermissionsExt;
281 match fs::metadata(&prompt_path) {
282 Ok(metadata) => {
283 let mut perms = metadata.permissions();
284 perms.set_mode(0o644); // Owner read-write, group/others read-only
285 if fs::set_permissions(&prompt_path, perms).is_err() {
286 writable_warning =
287 Some("Failed to set write permissions on PROMPT.md".to_string());
288 }
289 }
290 Err(_) => {
291 writable_warning = Some("Failed to read PROMPT.md metadata".to_string());
292 }
293 }
294 }
295
296 #[cfg(windows)]
297 {
298 match fs::metadata(&prompt_path) {
299 Ok(metadata) => {
300 let mut perms = metadata.permissions();
301 perms.set_readonly(false);
302 if fs::set_permissions(&prompt_path, perms).is_err() {
303 writable_warning =
304 Some("Failed to set write permissions on PROMPT.md".to_string());
305 }
306 }
307 Err(_) => {
308 writable_warning = Some("Failed to read PROMPT.md metadata".to_string());
309 }
310 }
311 }
312
313 writable_warning
314}
315
316// ============================================================================
317// Workspace-based backup functions
318// ============================================================================
319
320/// Create a backup of PROMPT.md using the Workspace abstraction.
321///
322/// This function mirrors `create_prompt_backup_at` but uses the `Workspace` trait
323/// for all file operations, allowing tests to use `MemoryWorkspace` instead of
324/// real filesystem access.
325///
326/// With backup rotation enabled, this maintains up to 3 backup versions:
327/// `.agent/PROMPT.md.backup`, `.agent/PROMPT.md.backup.1`, and `.agent/PROMPT.md.backup.2`.
328///
329/// # Behavior
330///
331/// - If PROMPT.md doesn't exist, returns `Ok(None)` (nothing to backup)
332/// - Creates the `.agent` directory if it doesn't exist
333/// - Rotates existing backups: backup.2 → deleted, backup.1 → backup.2, backup → backup.1
334/// - Sets all backup files to read-only (best-effort; failures don't error)
335/// - Returns a warning string in the Ok variant if read-only setting fails
336///
337/// # Returns
338///
339/// Returns `io::Result<Option<String>>` where:
340/// - `Ok(None)` - backup created and read-only set successfully
341/// - `Ok(Some(warning))` - backup created but read-only couldn't be set
342/// - `Err(e)` - if the backup cannot be created
343pub fn create_prompt_backup_with_workspace(
344 workspace: &dyn Workspace,
345) -> io::Result<Option<String>> {
346 let prompt_path = Path::new("PROMPT.md");
347
348 // If PROMPT.md doesn't exist, that's fine - nothing to backup
349 if !workspace.exists(prompt_path) {
350 return Ok(None);
351 }
352
353 // Ensure .agent directory exists
354 let agent_dir = Path::new(".agent");
355 let backup_base = Path::new(".agent/PROMPT.md.backup");
356 let backup_1 = Path::new(".agent/PROMPT.md.backup.1");
357 let backup_2 = Path::new(".agent/PROMPT.md.backup.2");
358
359 workspace.create_dir_all(agent_dir)?;
360
361 // Read PROMPT.md content
362 let content = workspace.read(prompt_path).map_err(|e| {
363 io::Error::new(
364 e.kind(),
365 format!("Failed to read PROMPT.md for backup: {e}"),
366 )
367 })?;
368
369 // Backup rotation: .2 → deleted, .1 → .2, .backup → .1
370 // Delete oldest backup if it exists
371 let _ = workspace.remove_if_exists(backup_2);
372
373 // Rotate .1 → .2
374 if workspace.exists(backup_1) {
375 let _ = workspace.rename(backup_1, backup_2);
376 }
377
378 // Rotate .backup → .1
379 if workspace.exists(backup_base) {
380 let _ = workspace.rename(backup_base, backup_1);
381 }
382
383 // Write new backup atomically to prevent corruption
384 workspace
385 .write_atomic(backup_base, &content)
386 .map_err(|e| io::Error::new(e.kind(), format!("Failed to write PROMPT.md backup: {e}")))?;
387
388 // Set read-only permissions on all backups (best-effort)
389 let mut readonly_warning = None;
390
391 for backup_path in [backup_base, backup_1, backup_2] {
392 if workspace.exists(backup_path) {
393 if let Err(e) = workspace.set_readonly(backup_path) {
394 if readonly_warning.is_none() {
395 readonly_warning = Some(e.to_string());
396 }
397 }
398 }
399 }
400
401 Ok(readonly_warning)
402}
403
404/// Make PROMPT.md read-only using the Workspace abstraction.
405///
406/// This function mirrors `make_prompt_read_only_at` but uses the `Workspace` trait
407/// for all file operations.
408///
409/// # Returns
410///
411/// Returns `Option<String>` where:
412/// - `None` - permissions set successfully or file doesn't exist
413/// - `Some(warning)` - couldn't set read-only permissions
414pub fn make_prompt_read_only_with_workspace(workspace: &dyn Workspace) -> Option<String> {
415 let prompt_path = Path::new("PROMPT.md");
416
417 // If PROMPT.md doesn't exist, that's fine - nothing to protect
418 if !workspace.exists(prompt_path) {
419 return None;
420 }
421
422 // Try to set read-only permissions
423 match workspace.set_readonly(prompt_path) {
424 Ok(()) => None,
425 Err(e) => Some(format!(
426 "Failed to set read-only permissions on PROMPT.md: {e}"
427 )),
428 }
429}
430
431/// Make PROMPT.md writable again using the Workspace abstraction.
432///
433/// This function mirrors `make_prompt_writable_at` but uses the `Workspace` trait
434/// for all file operations.
435///
436/// # Returns
437///
438/// Returns `Option<String>` where:
439/// - `None` - permissions restored successfully or file doesn't exist
440/// - `Some(warning)` - couldn't restore write permissions
441pub fn make_prompt_writable_with_workspace(workspace: &dyn Workspace) -> Option<String> {
442 let prompt_path = Path::new("PROMPT.md");
443
444 // If PROMPT.md doesn't exist, that's fine - nothing to modify
445 if !workspace.exists(prompt_path) {
446 return None;
447 }
448
449 // Try to restore write permissions
450 match workspace.set_writable(prompt_path) {
451 Ok(()) => None,
452 Err(e) => Some(format!("Failed to set write permissions on PROMPT.md: {e}")),
453 }
454}
455
456// ============================================================================
457// Diff backup functions for oversized content
458// ============================================================================
459
460/// Path for diff backup file.
461const DIFF_BACKUP_PATH: &str = ".agent/DIFF.backup";
462
463/// Write oversized diff content to a backup file.
464///
465/// When a diff exceeds the inline size limit, this function writes it
466/// to `.agent/DIFF.backup` so agents can read it if needed.
467///
468/// # Arguments
469///
470/// * `workspace` - Workspace for file operations
471/// * `diff_content` - The diff content to write
472///
473/// # Returns
474///
475/// Returns `Ok(PathBuf)` with the backup path on success, or an error.
476pub fn write_diff_backup_with_workspace(
477 workspace: &dyn Workspace,
478 diff_content: &str,
479) -> io::Result<std::path::PathBuf> {
480 let backup_path = Path::new(DIFF_BACKUP_PATH);
481
482 // Ensure .agent directory exists
483 workspace.create_dir_all(Path::new(".agent"))?;
484
485 // Write the diff content
486 workspace.write(backup_path, diff_content)?;
487
488 Ok(backup_path.to_path_buf())
489}
490
491// Note: Old tests using with_temp_cwd have been removed since production
492// code now uses workspace-based functions (_with_workspace variants).
493// The old std::fs functions are kept for backward compatibility but are
494// not tested here. See workspace_tests module below for the active tests.
495
496/// Tests for workspace-based backup functions
497#[cfg(all(test, feature = "test-utils"))]
498mod workspace_tests {
499 use super::*;
500 use crate::workspace::{MemoryWorkspace, Workspace};
501
502 #[test]
503 fn test_create_prompt_backup_with_workspace_creates_file() {
504 let workspace = MemoryWorkspace::new_test().with_file("PROMPT.md", "# Test Content\n");
505
506 let result = create_prompt_backup_with_workspace(&workspace);
507 assert!(result.is_ok());
508
509 // Backup should exist with same content
510 assert!(workspace.exists(Path::new(".agent/PROMPT.md.backup")));
511 assert_eq!(
512 workspace.get_file(".agent/PROMPT.md.backup").unwrap(),
513 "# Test Content\n"
514 );
515 }
516
517 #[test]
518 fn test_create_prompt_backup_with_workspace_missing_prompt() {
519 let workspace = MemoryWorkspace::new_test();
520 // No PROMPT.md exists
521
522 let result = create_prompt_backup_with_workspace(&workspace);
523 assert!(result.is_ok());
524 assert!(result.unwrap().is_none()); // No warning
525
526 // No backup should be created
527 assert!(!workspace.exists(Path::new(".agent/PROMPT.md.backup")));
528 }
529
530 #[test]
531 fn test_create_prompt_backup_with_workspace_rotation() {
532 let workspace = MemoryWorkspace::new_test()
533 .with_file("PROMPT.md", "# Version 1\n")
534 .with_dir(".agent");
535
536 // First backup
537 create_prompt_backup_with_workspace(&workspace).unwrap();
538 assert_eq!(
539 workspace.get_file(".agent/PROMPT.md.backup").unwrap(),
540 "# Version 1\n"
541 );
542
543 // Update PROMPT.md and create second backup
544 workspace
545 .write(Path::new("PROMPT.md"), "# Version 2\n")
546 .unwrap();
547 create_prompt_backup_with_workspace(&workspace).unwrap();
548
549 // Check rotation: .backup has v2, .backup.1 has v1
550 assert_eq!(
551 workspace.get_file(".agent/PROMPT.md.backup").unwrap(),
552 "# Version 2\n"
553 );
554 assert_eq!(
555 workspace.get_file(".agent/PROMPT.md.backup.1").unwrap(),
556 "# Version 1\n"
557 );
558
559 // Third backup
560 workspace
561 .write(Path::new("PROMPT.md"), "# Version 3\n")
562 .unwrap();
563 create_prompt_backup_with_workspace(&workspace).unwrap();
564
565 // Check: .backup=v3, .backup.1=v2, .backup.2=v1
566 assert_eq!(
567 workspace.get_file(".agent/PROMPT.md.backup").unwrap(),
568 "# Version 3\n"
569 );
570 assert_eq!(
571 workspace.get_file(".agent/PROMPT.md.backup.1").unwrap(),
572 "# Version 2\n"
573 );
574 assert_eq!(
575 workspace.get_file(".agent/PROMPT.md.backup.2").unwrap(),
576 "# Version 1\n"
577 );
578 }
579
580 #[test]
581 fn test_create_prompt_backup_with_workspace_deletes_oldest() {
582 let workspace = MemoryWorkspace::new_test().with_dir(".agent");
583
584 // Create 4 backups - oldest (v1) should be deleted
585 for i in 1..=4 {
586 workspace
587 .write(Path::new("PROMPT.md"), &format!("# Version {i}\n"))
588 .unwrap();
589 create_prompt_backup_with_workspace(&workspace).unwrap();
590 }
591
592 // Only 3 backups should exist
593 assert!(workspace.exists(Path::new(".agent/PROMPT.md.backup")));
594 assert!(workspace.exists(Path::new(".agent/PROMPT.md.backup.1")));
595 assert!(workspace.exists(Path::new(".agent/PROMPT.md.backup.2")));
596
597 // Content: .backup=v4, .backup.1=v3, .backup.2=v2 (v1 deleted)
598 assert_eq!(
599 workspace.get_file(".agent/PROMPT.md.backup").unwrap(),
600 "# Version 4\n"
601 );
602 assert_eq!(
603 workspace.get_file(".agent/PROMPT.md.backup.1").unwrap(),
604 "# Version 3\n"
605 );
606 assert_eq!(
607 workspace.get_file(".agent/PROMPT.md.backup.2").unwrap(),
608 "# Version 2\n"
609 );
610 }
611
612 #[test]
613 fn test_make_prompt_read_only_with_workspace() {
614 let workspace = MemoryWorkspace::new_test().with_file("PROMPT.md", "# Test\n");
615
616 // Should succeed (no-op for in-memory workspace, but function exists)
617 let result = make_prompt_read_only_with_workspace(&workspace);
618 assert!(result.is_none());
619 }
620
621 #[test]
622 fn test_make_prompt_read_only_with_workspace_missing() {
623 let workspace = MemoryWorkspace::new_test();
624 // No PROMPT.md
625
626 let result = make_prompt_read_only_with_workspace(&workspace);
627 assert!(result.is_none()); // No warning when file doesn't exist
628 }
629
630 #[test]
631 fn test_make_prompt_writable_with_workspace() {
632 let workspace = MemoryWorkspace::new_test().with_file("PROMPT.md", "# Test\n");
633
634 let result = make_prompt_writable_with_workspace(&workspace);
635 assert!(result.is_none());
636 }
637
638 #[test]
639 fn test_write_diff_backup_with_workspace() {
640 let workspace = MemoryWorkspace::new_test();
641 let diff = "+added\n-removed";
642
643 let result = write_diff_backup_with_workspace(&workspace, diff);
644 assert!(result.is_ok());
645
646 let path = result.unwrap();
647 assert_eq!(path, Path::new(".agent/DIFF.backup"));
648 assert_eq!(workspace.get_file(".agent/DIFF.backup").unwrap(), diff);
649 }
650
651 #[test]
652 fn test_write_diff_backup_creates_agent_dir() {
653 let workspace = MemoryWorkspace::new_test();
654 // No .agent directory exists
655
656 let diff = "some diff content";
657 let result = write_diff_backup_with_workspace(&workspace, diff);
658 assert!(result.is_ok());
659
660 // Verify .agent directory was created and file exists
661 assert!(workspace.exists(Path::new(".agent")));
662 assert!(workspace.exists(Path::new(".agent/DIFF.backup")));
663 assert_eq!(workspace.get_file(".agent/DIFF.backup").unwrap(), diff);
664 }
665
666 #[test]
667 fn test_write_diff_backup_overwrites_existing() {
668 let workspace = MemoryWorkspace::new_test().with_file(".agent/DIFF.backup", "old content");
669
670 let new_diff = "new diff content";
671 let result = write_diff_backup_with_workspace(&workspace, new_diff);
672 assert!(result.is_ok());
673
674 // Should have overwritten the old content
675 assert_eq!(workspace.get_file(".agent/DIFF.backup").unwrap(), new_diff);
676 }
677}